Navegar, criar pastas e ler arquivos pelo terminal (pwd, ls, cd, mkdir, cat, code .) em Linux, macOS e Windows, sabendo qual shell está usando.
Configurar o VS Code com as extensões recomendadas nesta trilha e um settings.json que formata o código ao salvar.
Usar as abas Elements, Console, Network e Lighthouse do DevTools, e o modo de dispositivo, para inspecionar qualquer página da Web.
Instalar o Node.js LTS por um gerenciador de versões (nvm no Linux/macOS, nvm-windows no Windows) e explicar por que isso é melhor do que o instalador comum.
Ler um package.json e usar npm init, npm install, npm run e npx com segurança.
Conferir versões (node -v, npm -v, git --version, npm ls) e diagnosticar um ambiente quebrado.
Padronizar indentação, codificação e fim de linha em qualquer editor com um .editorconfig.
[ ] Um computador em que você possa instalar programas (Linux, macOS ou Windows 10/11).
[ ] Cerca de 2 GB livres em disco e acesso à internet.
[ ] Uma conta de e-mail que você acesse — vai servir para o GitHub no Capítulo 02.
Este é o primeiro capítulo da trilha Deploy & Ferramentas; não há capítulo anterior para retomar. Se você está no Nível 1, faça-o antes ou junto com a Aula 02 — tudo aqui usa só HTML, CSS e o navegador. Se está no Nível 2 ou 3, use-o como revisão do ambiente: é comum descobrir que o node -v da máquina é de dois anos atrás. Hoje você monta a bancada de trabalho; no Capítulo 02, aprende a guardar o histórico dela com Git.
Um site é só um conjunto de arquivos de texto. Dá para escrever HTML no Bloco de Notas e abrir com dois cliques — e nas primeiras aulas do Nível 1 isso basta. O problema aparece na terceira semana: você tem cinco páginas, uma pasta de imagens, um CSS que não recarrega, um colega que quer ver o site na máquina dele e, no fim do semestre, precisa publicar tudo em um endereço público.
Cada ferramenta deste capítulo resolve uma dor específica:
Ferramenta
Dor que resolve
Terminal
Criar, mover e inspecionar arquivos sem clicar; rodar programas (Git, npm, Vite) que não têm janela
VS Code + extensões
Escrever código com destaque de sintaxe, autocompletar, formatação automática e servidor local
DevTools
Enxergar o que o navegador fez com o seu HTML/CSS/JS — e por que não fez o que você esperava
Node.js + npm
Rodar JavaScript fora do navegador e instalar as ferramentas do ofício (Prettier, ESLint, Vite, Express)
.editorconfig
Garantir que o código fique igual no seu editor e no de qualquer outra pessoa que abrir o projeto
Você não precisa dominar tudo hoje. Precisa instalar tudo hoje, entender para que serve cada peça e saber onde conferir se está funcionando. É o que a maioria dos "não roda na minha máquina" do semestre tem em comum: uma peça faltando na bancada.
🧠 Você sabia?
O bash, terminal padrão da maioria das distribuições Linux, é mais velho que a Web. Foi escrito por Brian Fox para o projeto GNU, e o nome é um trocadilho: Bourne Again SHell, em homenagem ao sh de Stephen Bourne, que ele substituiu. Os comandos ls, cd e cat que você vai usar hoje são os mesmos que um programador usava em um terminal de texto verde décadas antes de existir navegador.
O terminal é um programa que recebe comandos digitados e mostra o resultado em texto. O que interpreta os comandos se chama shell, e há vários. Para que os comandos deste material funcionem iguais em todas as máquinas, a recomendação é:
Sistema
Terminal recomendado
Como abrir
Linux (Ubuntu, Mint, Fedora)
bash (já vem instalado)
Ctrl+Alt+T ou "Terminal" no menu
macOS
Terminal.app (shell zsh, compatível com tudo daqui)
Cmd+Espaço, digite "Terminal"
Windows
Git Bash (instalado junto com o Git for Windows)
Menu Iniciar → "Git Bash"
No Windows existe também o PowerShell, que aceita ls, cd, pwd, cat e mkdir como apelidos de comandos próprios — mas sem as opções do Linux (ls -la dá erro lá) e sem touch. Para não ter dois conjuntos de comandos na cabeça, use o Git Bash ao longo de toda esta trilha. Você instala o Git for Windows no passo a passo deste capítulo e ganha os dois de uma vez: o git e um shell compatível com Linux.
💡 Dica
Quem usa Windows e quer um Linux "de verdade" dentro do Windows pode instalar o WSL (wsl --install em um PowerShell como administrador). Não é obrigatório para nada do WebLab; o Git Bash resolve tudo do que precisamos.
Para descobrir qual shell está rodando:
Terminal
echo$SHELL
Saída típica: /bin/bash (Linux, Git Bash) ou /bin/zsh (macOS).
Todo terminal está sempre "dentro" de uma pasta, chamada diretório de trabalho. Três comandos resolvem 80 % da navegação:
Terminal
pwd# print working directory: mostra em que pasta você está
ls# lista o conteúdo da pasta atualcdDocumentos# change directory: entra na pasta Documentos
Variações de ls que você vai usar todo dia:
Terminal
ls-l# formato longo: permissões, dono, tamanho, data de cada item
ls-a# inclui arquivos ocultos (os que começam com ponto, como .gitignore)
ls-la# os dois juntos — o mais usado
lscss# lista o conteúdo de uma subpasta sem entrar nela
Variações de cd:
Terminal
cd..# sobe um nível (para a pasta "pai")cd../..# sobe dois níveiscd~# vai para a sua pasta pessoal (home)cd# o mesmo que cd ~cd-# volta para a pasta em que você estava antescd/# vai para a raiz do sistema de arquivos
🔬 Investigue
Abra o terminal e rode pwd, depois ls -la. Quantos itens começam com ponto? Eles não aparecem no gerenciador de arquivos comum — são configurações de programas (.bashrc, .config, .ssh). Agora rode cd / e ls: você está vendo a raiz do sistema. Volte com cd -. Em nenhum momento você "quebrou" nada: navegar é só olhar.
mkdirprojetos# cria a pasta projetos
mkdir-pprojetos/site/css# cria a cadeia inteira de pastas de uma vez (-p = parents)
touchindex.html# cria um arquivo vazio (ou atualiza a data de um existente)
catindex.html# mostra o conteúdo de um arquivo de texto na tela
cpindex.htmlcontato.html# copia
mvcontato.htmlpaginas/# move (ou renomeia, se o destino for um nome de arquivo)
rmrascunho.html# apaga um arquivo — sem lixeira, sem desfazer
rm-rpasta-velha# apaga uma pasta e tudo dentro dela — cuidado dobrado
clear# limpa a tela (ou Ctrl+L)
E o comando que você mais vai digitar neste semestre:
Terminal
code.
Abre o VS Code com a pasta atual como projeto. O ponto significa "aqui". Abrir a pasta (e não um arquivo solto) é o que faz o Live Server, o Git e as extensões entenderem onde o projeto começa.
⚠️ Atençãorm não pergunta e não tem lixeira. rm -rf com o caminho errado apaga pastas inteiras em silêncio. Antes de qualquer rm -r, rode ls no alvo para ter certeza do que vai sumir. E nunca copie um comando com rm -rf da internet sem entender cada parte dele.
Um caminho absoluto começa na raiz e não depende de onde você está: /home/ana/projetos/site/index.html (Linux), /Users/ana/projetos/site/index.html (macOS), C:\Users\ana\projetos\site\index.html (Windows — no Git Bash aparece como /c/Users/ana/…).
Um caminho relativo parte do diretório de trabalho: se você está em projetos/site, então css/estilo.css é o arquivo dentro da subpasta css, e ../outro-site é a pasta irmã.
Essa distinção volta com força no Capítulo 03: um href="/css/estilo.css" (absoluto, começa com barra) funciona no seu computador e quebra quando o site é publicado em um subcaminho como usuario.github.io/site-evento/. Guarde a ideia: barra no início = "a partir da raiz".
Tab completa nomes de arquivos e pastas. Digite cd Doc e aperte Tab: vira cd Documentos/. Dois Tab mostram as opções quando há mais de uma.
↑ e ↓ percorrem o histórico de comandos. history lista tudo.
Ctrl+C interrompe o programa em execução (é assim que você para um servidor de desenvolvimento).
Ctrl+R busca no histórico: digite parte de um comando antigo e ele aparece.
Arraste uma pasta do gerenciador de arquivos para a janela do terminal: o caminho absoluto dela é colado.
Nomes com espaço precisam de aspas ou barra invertida: cd "Meus Projetos" ou cd Meus\ Projetos. Melhor ainda: não use espaços nem acentos em nomes de pastas de projeto. site-evento, não Site do Evento.
Extensões são instaladas pela aba Extensions (Ctrl+Shift+X) ou pelo terminal, o que é mais rápido e reproduzível. O identificador de cada uma é editor.nome:
Extensão
Identificador
Para que serve
Live Server
ritwickdey.LiveServer
Servidor local que recarrega o navegador a cada salvamento — Nível 1 inteiro
Prettier
esbenp.prettier-vscode
Formata HTML, CSS, JS, JSON e Vue com um padrão único
ESLint
dbaeumer.vscode-eslint
Aponta erros e maus hábitos no JavaScript enquanto você digita
Vue - Official
Vue.volar
Suporte a arquivos .vue (destaque, autocompletar, erros) — Nível 3
REST Client
humao.rest-client
Testa APIs a partir de arquivos .http sem sair do editor — Níveis 2 e 3
GitLens
eamodio.gitlens
Mostra quem mudou cada linha e quando; histórico visual — Capítulo 02 em diante
💡 Dica
Prefere o VS Code em português? Instale MS-CEINTL.vscode-language-pack-pt-BR. Este material usa os nomes em inglês dos menus e abas (Settings, Extensions, Elements) porque é assim que aparecem na documentação oficial e nas respostas do Stack Overflow — vale a pena se acostumar com eles.
editor.formatOnSave + editor.defaultFormatter: ao salvar, o Prettier reorganiza indentação, aspas e quebras de linha. Você para de gastar atenção com estética e passa a gastar com lógica.
editor.tabSize: 2 + insertSpaces: dois espaços por nível, o padrão do ecossistema JavaScript/Vue.
editor.linkedEditing: renomear a tag de abertura de um elemento HTML renomeia a de fechamento junto.
files.eol: "\n": fim de linha no padrão Unix. Evita o aviso LF will be replaced by CRLF do Git no Windows.
emmet.variables.lang: o atalho ! do Emmet passa a gerar <html lang="pt-BR"> em vez de en.
terminal.integrated.defaultProfile.windows: o terminal integrado do VS Code no Windows abre o Git Bash, não o PowerShell.
Há também configurações por projeto, em .vscode/settings.json dentro da pasta do projeto. Elas valem só ali e podem ser versionadas — útil para uma equipe usar o mesmo formatador.
O Prettier tem opiniões fortes e poucas opções. As que mudamos no WebLab ficam em um arquivo na raiz do projeto, e o estilo bate com o código que você vê nas aulas (sem ponto e vírgula, aspas simples):
Sem esse arquivo, o Prettier usa os padrões dele (com ponto e vírgula e aspas duplas). Nenhum dos dois estilos é "errado" — o que importa é o projeto inteiro seguir um só.
O .prettierrc fala com o Prettier; o .editorconfig fala com qualquer editor (VS Code, Sublime, IntelliJ, Vim). Ele define o básico da forma do arquivo — indentação, codificação, fim de linha — e é lido pela extensão EditorConfig. Coloque na raiz de todo projeto:
.editorconfig
Config
# Configuração lida por qualquer editor com suporte a EditorConfigroot=true[*]charset=utf-8end_of_line=lfinsert_final_newline=truetrim_trailing_whitespace=trueindent_style=spaceindent_size=2[*.md]trim_trailing_whitespace=false
root = true diz "pare de procurar .editorconfig nas pastas acima". A seção [*] vale para todo arquivo; a [*.md] abre uma exceção para Markdown, em que dois espaços no fim da linha significam quebra de linha.
Todo navegador moderno traz ferramentas de desenvolvedor. Abra com F12, Ctrl+Shift+I ou botão direito → Inspecionar (no macOS, Cmd+Option+I). Os exemplos aqui são do Chrome; o Firefox tem as mesmas abas com nomes quase iguais.
A aba Elements mostra a árvore DOM: não o seu arquivo, mas o que o navegador construiu a partir dele (com tags que ele fechou por você, com o que o JavaScript inseriu). Ao clicar em um elemento:
O painel Styles lista cada regra CSS que atinge o elemento, em ordem de prioridade, com as sobrescritas riscadas. É onde você descobre por que "meu CSS não aplica": outra regra mais específica venceu, ou o seletor não bate.
Você pode editar valores ao vivo (clique em 16px e digite 24px) para experimentar sem tocar no arquivo. Nada disso é salvo — é rascunho.
O painel Computed mostra o valor final de cada propriedade e o diagrama de caixa (margin, border, padding, conteúdo).
A lupa no canto superior esquerdo (Ctrl+Shift+C) seleciona um elemento clicando na página.
A aba Console mostra mensagens de console.log, avisos e erros em vermelho com o arquivo e a linha (script.js:12). Também é um interpretador: digite document.title e aperte Enter; digite 2 + 2; digite document.querySelectorAll('a').length para contar os links da página. Nas aulas de JavaScript do Nível 1 você vai passar mais tempo aqui do que no editor.
A aba Network grava tudo o que o navegador baixou: HTML, CSS, imagens, fontes, scripts, chamadas a APIs. Para cada requisição: Status (200 ok, 404 não encontrado, 304 não mudou), Type, Size e Time. A linha do tempo à direita (o waterfall) mostra o que esperou por quem.
Três hábitos:
Abra o DevTools antes de recarregar (F5), senão a aba fica vazia.
Marque Disable cache enquanto desenvolve, para sempre ver a versão nova do CSS.
Use o seletor de velocidade (No throttling → Slow 4G) para sentir o site como quem está no 4G ruim do interior.
Na barra de baixo, o resumo: número de requisições, total transferido, tempo até DOMContentLoaded e até Load.
Ctrl+Shift+M liga o modo de dispositivo: a página é renderizada nas dimensões de um celular ou tablet, com toque simulado. Escolha um aparelho na lista ou digite largura e altura. É a ferramenta central da aula de telas responsivas do Nível 1 — mas lembre que é uma simulação: fontes, desempenho e o teclado virtual só se veem em um celular de verdade.
A aba Lighthouse roda uma auditoria automática e dá notas de 0 a 100 em quatro categorias: Performance, Accessibility, Best Practices e SEO. Escolha Mobile, clique em Analyze page load e espere um minuto. Cada item reprovado vem com explicação e link. É o critério objetivo de "site bom" usado nos checkpoints das trilhas: ≥ 90 em Performance e Accessibility.
Sources permite colocar pontos de parada (breakpoints) no JavaScript e executar linha a linha — você vai usar nas aulas de funções e eventos. Application mostra localStorage, cookies e service workers — aparece no Nível 3, quando o UniEventos guarda inscrições no navegador.
🔬 Investigue
Abra https://weblab.aprendabit.com com a aba Network aberta e Disable cache marcado. Recarregue. Anote: quantas requisições? Qual o maior arquivo (clique no cabeçalho Size para ordenar)? Quanto tempo até DOMContentLoaded? Agora mude para Slow 4G e recarregue de novo. O que mais demorou? Esse é o tipo de medição que você vai repetir no seu próprio site publicado, no Capítulo 03.
O Node.js roda JavaScript fora do navegador. Você só vai escrever servidores com ele no Nível 2 — mas precisa dele desde já porque as ferramentas do ofício são escritas em JavaScript: o Prettier que formata seu CSS, o ESLint que aponta erros, o Vite que empacota o Vue, o serve que sobe um servidor local. Todas são instaladas e executadas pelo npm, o gerenciador de pacotes que vem junto com o Node.
Pense no npm como uma loja de aplicativos para código: qualquer pessoa publica um pacote (uma pasta com JavaScript e um package.json), e qualquer projeto instala esse pacote com um comando. O registro público tem milhões de pacotes, do minúsculo ao gigantesco.
🧠 Você sabia?
Um pacote chamado left-pad, com 11 linhas que só adicionavam espaços à esquerda de um texto, era dependência indireta de milhares de projetos. Quando o autor o removeu do npm em uma disputa, builds do mundo inteiro quebraram na mesma tarde — e o npm mudou as regras para impedir a remoção de pacotes publicados. É a lição de que todo npm install é um ato de confiança em desconhecidos; o package-lock.json, que você vai ver adiante, existe para tornar essa confiança pelo menos reproduzível.
O Node tem duas linhas: Current (versões ímpares, novidades primeiro, suporte curto) e LTS (Long Term Support — versões pares, recebem correções por anos). Use sempre a LTS: hoje, a linha 22 (a 24 também é LTS e funciona). O create-vue do Nível 3 exige ^22.18.0 || >=24.12.0, o que é mais um motivo para não ficar com um Node velho.
Há dois jeitos de instalar. O instalador de https://nodejs.org funciona, mas deixa uma versão única e fixa, e no Linux/macOS costuma exigir sudo para instalar pacotes globais. O jeito recomendado é um gerenciador de versões, que instala o Node na sua pasta pessoal (sem sudo) e permite ter várias versões lado a lado — o projeto antigo de um freela em Node 18, o projeto novo em Node 22.
Feche e abra o terminal (o instalador acrescenta linhas ao ~/.bashrc ou ~/.zshrc, que só são lidas em um terminal novo). Depois:
Terminal
nvminstall--lts# baixa e instala a LTS mais recente
nvmuse--lts# ativa nesta sessão
nvmaliasdefault'lts/*'# torna a LTS o padrão de todo terminal novo
nvmls# lista as versões instaladas (a ativa tem ->)
node-v# v22.x.x (ou v24.x.x)
npm-v# 10.x.x ou 11.x.x
Para uma versão específica: nvm install 22, nvm use 22.
nvminstalllts# instala a LTS mais recente
nvmlist# mostra o número exato da versão instalada
nvmuselts# ativa (pode pedir um terminal como administrador)
node-v
npm-v
Se preferir não usar gerenciador no Windows, o instalador oficial também serve: winget install OpenJS.NodeJS.LTS em um PowerShell, ou o .msi do site. Você perde a troca de versões, mas ganha simplicidade.
Cria um package.json com valores padrão (o -y aceita todas as perguntas). Em seguida:
Terminal
npminstallprettier--save-dev# instala um pacote como dependência de desenvolvimento
npminstall# instala tudo que o package.json lista (ao clonar um projeto)
npmrunformatar# executa o script "formatar" definido em package.json
npxprettier--check.# executa um pacote sem instalar globalmente
npm install <pacote> (ou npm i) baixa o pacote para node_modules/ e o registra em dependencies. Com -D (ou --save-dev), registra em devDependencies — coisas que só existem enquanto você desenvolve (formatador, empacotador, ferramentas de teste), e não no site final.
npm install sem argumentos lê package.json e package-lock.json e recria node_modules/ inteira. É o primeiro comando que você roda ao baixar qualquer projeto Node.
npm run <nome> executa um comando definido em scripts. npm run sem nome lista os scripts disponíveis.
npx <comando> procura o executável em node_modules/.bin do projeto; se não existir, baixa uma cópia temporária e pergunta se pode executar. É assim que você usa npx create-vue@latest sem instalar nada antes.
Depois de npm init -y, npm install -D prettier serve e a edição dos scripts, o arquivo de um projeto de teste fica assim (a ordem dos campos e um ou outro campo extra variam com a versão do npm):
package.json
JSON
{"name":"ola-weblab","version":"1.0.0","description":"Projeto de teste do ambiente WebLab","scripts":{"dev":"serve . -l 3000","formatar":"prettier --write .","verificar":"prettier --check ."},"keywords":[],"author":"Seu Nome","license":"MIT","devDependencies":{"prettier":"^3.6.2","serve":"^14.2.4"}}
Campo a campo:
Campo
Significado
name, version
Identidade do projeto. Só importam de verdade se você publicar um pacote no npm
scripts
Comandos com apelido. npm run dev executa serve . -l 3000
dependencies
Pacotes que o código final precisa para rodar (ex.: express, vue, axios)
devDependencies
Pacotes usados só durante o desenvolvimento (ex.: prettier, vite, nodemon)
license
Sob que termos outras pessoas podem usar seu código — voltamos a isso no Capítulo 02
O ^ antes da versão é semver (versionamento semântico): ^3.6.2 aceita qualquer 3.x.y a partir de 3.6.2, mas não 4.0.0. O primeiro número muda quando há quebra de compatibilidade; o segundo, quando há funcionalidade nova; o terceiro, quando há correção.
🔎 Por baixo do capô
Ao rodar npm install, o npm lê package.json, resolve a árvore de dependências (cada pacote depende de outros, que dependem de outros), baixa tudo do registro para node_modules/ e grava em package-lock.json a versão exata de cada pacote instalado. Os executáveis dos pacotes ganham um atalho em node_modules/.bin/ — é por isso que serve funciona dentro de scripts sem caminho: o npm coloca essa pasta no PATH enquanto roda um script. E é por isso que npx serve acha o serve do projeto antes de pensar em baixar qualquer coisa.
Duas regras que evitam metade dos problemas de Git do semestre:
node_modules/ nunca vai para o Git nem para o .zip. É regenerável com npm install, pesa dezenas ou centenas de megabytes e tem milhares de arquivos.
package-lock.json sempre vai para o Git. Ele garante que qualquer pessoa — um colega ou o servidor — instale exatamente as mesmas versões que você testou.
Quando algo "não funciona", a primeira pergunta é: qual versão? Comandos de diagnóstico:
Terminal
node-v# versão do Node ativa
npm-v# versão do npm
git--version# versão do Git
nvmcurrent# qual Node o nvm ativou nesta sessão (Linux/macOS)
whichnode# de onde o executável node está vindo
npmls--depth=0# pacotes instalados no projeto, sem as dependências das dependências
npmoutdated# o que tem versão nova disponível
npmviewviteversion# última versão publicada de um pacote
Para fixar a versão do Node por projeto, crie um .nvmrc na raiz com o número da linha:
.nvmrc
Texto
22
Quem entrar na pasta e rodar nvm use recebe a versão certa sem pensar. Muitos projetos também declaram o mesmo em package.json, no campo engines:
JSON
{"engines":{"node":">=22.18.0"}}
⚠️ Atenção
Se node -v mostra uma versão diferente da que você acabou de instalar com o nvm, há dois Nodes na máquina: um do instalador antigo, outro do nvm. which -a node lista todos. Desinstale o antigo (ou deixe nvm alias default decidir) para não passar o semestre com um ambiente que muda a cada terminal.
🚀 Passo a passo — Ambiente pronto e um projeto de teste no Live Server¶
Ao fim destes passos, sua máquina responde a node -v, npm -v e git --version, o VS Code tem as extensões recomendadas, e um projeto mínimo abre no navegador pelo Live Server e recarrega sozinho ao salvar. Faça na ordem; cada passo tem um jeito de conferir.
Windows: baixe e instale o Git for Windows em https://git-scm.com/download/win (ou winget install --id Git.Git -e --source winget no PowerShell). Aceite os padrões; ele instala o Git Bash.
macOS: no Terminal, git --version. Se não estiver instalado, o sistema oferece as Command Line Tools; aceite. Alternativa: brew install git.
Linux (Ubuntu/Debian):sudo apt update && sudo apt install git.
Esperado: três linhas, começando pela versão. No Windows, use o Git Bash; no macOS, só funciona depois do Shell Command: Install 'code' command in PATH.
No VS Code, Ctrl+Shift+P → Preferences: Open User Settings (JSON) → cole o conteúdo da §3.3 → salve. Se o arquivo já tinha conteúdo, mescle dentro das mesmas chaves { }.
index.html (digite ! e Tab para o esqueleto, depois complete)
HTML
<!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"/><metaname="viewport"content="width=device-width, initial-scale=1.0"/><title>Olá, WebLab</title><linkrel="stylesheet"href="css/estilo.css"/></head><body><main><h1>Ambiente pronto</h1><p>Se esta página recarregou sozinha quando você salvou, o Live Server está funcionando.</p><pid="relogio">Carregando o relógio pelo JavaScript…</p></main><scriptsrc="js/script.js"></script></body></html>
css/estilo.css
CSS
/* Estilo mínimo só para provar que o CSS foi carregado */body{font-family:system-ui,sans-serif;max-width:40rem;margin:2remauto;padding:01rem;background:#f5f5f0;color:#222;}h1{color:#1b6b4a;}
js/script.js
JavaScript
// Mostra a hora atual e prova que o JavaScript foi carregadoconstrelogio=document.querySelector('#relogio')functionatualizarRelogio(){constagora=newDate()relogio.textContent=`Agora são ${agora.toLocaleTimeString('pt-BR')}`}atualizarRelogio()setInterval(atualizarRelogio,1000)console.log('Ambiente WebLab: JavaScript carregado com sucesso')
Com index.html aberto, clique em Go Live no canto inferior direito da janela (ou botão direito no arquivo → Open with Live Server). O navegador abre http://127.0.0.1:5500/index.html.
Mude o texto do <h1> e salve: a página recarrega sozinha. Abra o DevTools (F12) → Console: a mensagem Ambiente WebLab: JavaScript carregado com sucesso está lá. Aba Network, recarregue: quatro requisições (index.html, estilo.css, script.js e um favicon.ico com 404 — normal, ainda não há favicon; o Capítulo 03 resolve).
No terminal integrado do VS Code (Ctrl+</kbd>), dentro deola-weblab`:
Terminal
npminit-y
npminstall-Dprettierserve
Crie .prettierrc e .editorconfig com o conteúdo das §3.4 e §3.5, e acrescente os scripts ao package.json até ele ficar como na §5.4. Depois:
Terminal
npmrunformatar
npmrundev
Esperado: o Prettier lista os arquivos formatados; o serve sobe em http://localhost:3000 (é um segundo servidor local, sem recarga automática — serve para conferir que npm run funciona). Pare com Ctrl+C.
A1. Você está em ~/weblab/site-evento/css e roda cd .. e depois pwd. O que aparece? E se em seguida rodar cd ~ e pwd?
A2. Qual a diferença entre npm install prettier e npm install -D prettier? Em que campo do package.json cada um registra o pacote, e qual dos dois é o certo para um formatador?
A3. Preveja o que acontece ao rodar npx serve . em uma pasta que não tem o serve instalado nem localmente nem globalmente. O comando falha?
A4. Em uma pasta vazia, mkdir -p projetos/site/css cria quantas pastas? O que muda se projetos já existir? E o que acontece com mkdir projetos/site/css (sem -p) na pasta vazia?
A5. Para cada situação, diga qual aba do DevTools responde: (a) "por que o color: red não aplicou neste parágrafo?"; (b) "qual arquivo voltou com 404?"; (c) "o que meu console.log imprimiu?"; (d) "como o site fica em uma tela de 375 px?"; (e) "qual a nota de acessibilidade da página?".
A6.node -v e nvm current podem mostrar valores diferentes? Descreva uma situação em que isso acontece e como corrigir.
B1. Estrutura em um comando. Crie, com um únicomkdir e um únicotouch, a estrutura do site do evento: site-evento/index.html, programacao.html, inscricao.html, palestrantes.html, contato.html, css/estilo.css, js/script.js e a pasta img/ vazia. Mostre o resultado com ls -R.
O bash expande chaves: mkdir -p site-evento/{css,js,img} cria as três subpastas de uma vez, e touch site-evento/{index,programacao,inscricao,palestrantes,contato}.html cria os cinco HTML. Combine com os caminhos de css/ e js/ no mesmo touch.
B2. Formatação automática. Crie js/bagunca.js com exatamente este conteúdo, salve e observe o que o Prettier faz:
JavaScript
constprodutos=[{nome:"Café",preco:5},{nome:"Pão de queijo",preco:4}]functiontotal(lista){letsoma=0;for(constpoflista){soma+=p.preco};returnsoma}console.log(total(produtos));
Resultado esperado: ao salvar, o arquivo fica com dois espaços de indentação, aspas simples, sem ponto e vírgula, um item por linha no array e o for em várias linhas — sem você tocar em nada.
Dica
Se nada acontecer ao salvar, abra a paleta de comandos e execute Format Document With… → Prettier; se o Prettier não aparecer na lista, a extensão não está instalada. Se aparecer mas o resultado tiver ponto e vírgula, o .prettierrc não está na raiz da pasta aberta no VS Code.
B3. Caçador de requisições. Com a aba Network aberta e Disable cache marcado, carregue a página inicial de três sites: https://weblab.aprendabit.com, o portal da sua universidade ou escola e um site de notícias à sua escolha. Para cada um, registre: número de requisições, total transferido, maior arquivo (nome e tamanho) e tempo até DOMContentLoaded. Repita com Slow 4G.
Resultado esperado: uma tabela com três linhas e as medições nas duas velocidades, mais uma frase por site dizendo qual recurso mais atrasou o carregamento no 4G lento.
Dica
Os totais ficam na barra de status na parte de baixo da aba Network. Clique no cabeçalho Size para ordenar por tamanho e no Waterfall para ver quem esperou por quem. O seletor de velocidade fica ao lado de Disable cache.
B4. Scripts próprios. No projeto ola-weblab, acrescente ao package.json um script abrir que executa code ., e um script limpar que apaga node_modules (rm -rf node_modules). Rode npm run limpar, confira com ls -a que a pasta sumiu, e depois npm install para recriá-la.
Resultado esperado: npm run lista cinco scripts; depois de npm install, npm ls --depth=0 volta a mostrar prettier e serve com as mesmas versões de antes (é o package-lock.json garantindo isso).
Dica
Scripts são strings de shell comuns. Compare a saída de npm ls --depth=0 antes e depois: as versões batem porque o lock foi respeitado, não porque você teve sorte.
C1. Gerador de projeto. Escreva um script novo-projeto.sh que receba um nome (bash novo-projeto.sh cardapio) e: recuse se a pasta já existir; crie a pasta com index.html (esqueleto completo com lang="pt-BR", viewport e título igual ao nome), css/estilo.css, js/script.js, .editorconfig e .prettierrc; e termine abrindo o VS Code na pasta. Torne-o executável e use-o para criar a pasta do seu projeto autoral.
Dica
O primeiro argumento é $1. if [ -d "$1" ]; then echo "já existe"; exit 1; fi faz a recusa. Para gravar arquivos com várias linhas dentro do script, use um heredoc: cat > "$1/index.html" <<'EOF' seguido das linhas e de EOF sozinho em uma linha. chmod +x novo-projeto.sh dá permissão de execução.
Você instalou um pacote — prettier — e a pasta node_modules apareceu. Quantos arquivos ela tem? E se instalar vite? A resposta explica, melhor do que qualquer regra, por que node_modules nunca entra em um .zip, em um e-mail ou no Git.
Critérios de pronto
Um arquivo relatorio.md com uma tabela: pacote instalado, número de arquivos em node_modules, tamanho em disco, número de pacotes em package-lock.json.
Medições para três cenários: só prettier; prettier + serve; prettier + serve + vite.
Os comandos exatos usados para contar e medir, colados no relatório.
Três linhas explicando por que package.json + package-lock.json (alguns KB) substituem node_modules (dezenas de MB) na hora de compartilhar o projeto.
Pistas
find node_modules -type f | wc -l conta arquivos; du -sh node_modules mede o tamanho.
Para contar pacotes no lock, grep -c '"node_modules/' package-lock.json é uma aproximação boa.
npm ls --depth=0 mostra só o que você pediu; npm ls --all mostra a árvore inteira — compare os dois tamanhos.
Apague e recrie com rm -rf node_modules && npm install para ver quanto tempo o npm leva; é o preço que o colega paga ao clonar seu projeto, e é baixo.
Formatou o computador? Vai usar a máquina do laboratório? Profissionais guardam suas configurações em um repositório de dotfiles e reinstalam tudo com um script. Monte o seu: uma pasta bancada com um instalar.sh que, em uma máquina limpa, deixa o VS Code exatamente como o seu — extensões, settings.json, .prettierrc e .editorconfig padrão.
Critérios de pronto
extensoes.txt gerado a partir do seu VS Code, um identificador por linha.
bash instalar.sh instala todas as extensões da lista e copia o settings.json para o lugar certo no sistema em que está rodando (Linux, macOS ou Windows/Git Bash), sem perguntar nada.
Rodar o script duas vezes seguidas não dá erro nem duplica nada.
Testado na máquina de um colega (ou em um usuário novo do seu sistema), com o resultado de code --list-extensions colado no README.md da pasta.
No Capítulo 02 você vai publicar essa pasta como o repositório bancada — é exatamente o material que o Boss daquele capítulo pede.
Pistas
code --list-extensions > extensoes.txt gera a lista; xargs -L1 code --install-extension < extensoes.txt instala linha a linha.
O settings.json do usuário fica em ~/.config/Code/User/ (Linux), ~/Library/Application Support/Code/User/ (macOS) e $APPDATA/Code/User/ (Windows, no Git Bash). uname -s diz em qual sistema o script está.
Um case "$(uname -s)" in Linux*) … ;; Darwin*) … ;; MINGW*|MSYS*) … ;; esac escolhe o caminho.
mkdir -p antes de copiar e cp -f para sobrescrever tornam o script idempotente. Considere ln -s em vez de cp, para que editar o arquivo no VS Code atualize o repositório.
Por que um site de notícias demora oito segundos no 4G e o WebLab demora um? Hoje você deixa de adivinhar. Escolha três sites reais (um portal de notícias, uma loja online e um site de universidade), audite cada um no DevTools e escreva um laudo com a causa raiz da lentidão de cada um — e uma correção que você mesmo consegue demonstrar.
Critérios de pronto
Para cada site: nota de Performance do Lighthouse (modo Mobile), total transferido, número de requisições e o recurso que mais bloqueou a renderização (com evidência da aba Network ou Performance).
Percentual de JavaScript e CSS não usados na carga inicial, medido com a aba Coverage.
Um laudo de uma página por site: o gargalo, por que ele acontece e uma correção concreta (formato de imagem, carregamento adiado, fonte, script de terceiros).
Uma das correções demonstrada: salve a página (Save as… → Webpage, Complete), aplique a correção localmente (ex.: converta as imagens para WebP com https://squoosh.app) e mostre o antes e o depois com Network e Lighthouse.
Este é o tipo de investigação que compõe bem o seu Marco de responsividade/performance, se você quiser incluí-la no projeto autoral.
Pistas
A aba Coverage abre pela paleta do DevTools (Ctrl+Shift+P dentro do DevTools → "Show Coverage"); grave a carga e ordene por bytes não usados.
Na aba Network, filtre por Img e ordene por Size; imagens acima de 300 KB em uma página de notícias quase sempre são o primeiro gargalo.
A aba Performance grava um perfil: procure o marcador LCP (Largest Contentful Paint) e veja o que estava carregando antes dele.
O relatório do Lighthouse já lista "Eliminate render-blocking resources" e "Serve images in next-gen formats" com estimativa de ganho em segundos — use-a para priorizar a correção que vai demonstrar.
Monte o ambiente completo e crie a pasta do seu projeto autoral (o site com o tema que você escolheu na sua trilha):
Siga o passo a passo até o fim; corrija qualquer item da tabela "Como conferir" que não bata.
Crie a pasta do projeto autoral em ~/weblab/<nome-do-projeto> (sem espaços nem acentos) com index.html, css/estilo.css, js/script.js, .editorconfig e .prettierrc. O index.html deve ter lang="pt-BR", viewport, um <title> com o nome do projeto e um <h1>.
Abra com o Live Server e deixe um console.log com o nome do projeto no script.js.
Tire três capturas de tela: (a) o terminal com a saída de node -v, npm -v e git --version; (b) o terminal com code --list-extensions; (c) o navegador com o site no Live Server e o DevTools aberto na aba Console mostrando a sua mensagem.
Critério de pronto: as três capturas mostram versões LTS do Node, as sete extensões e a mensagem no Console; a pasta do projeto tem os cinco arquivos e não contém node_modules.
Guarde: um .zip da pasta do projeto (sem node_modules) e as três capturas em um único PDF, num lugar seguro. A partir do Capítulo 02, isso vira o link do repositório no GitHub.
No próximo capítulo, a bancada ganha memória: Git para guardar cada versão do projeto e GitHub para publicá-lo, colaborar e abrir o seu primeiro pull request.
Explicar o que é um commit, o que ele guarda e por que o Git é diferente de uma pasta com cópias numeradas.
Descrever as três áreas do Git (diretório de trabalho, área de preparação e repositório) e dizer em qual delas cada arquivo está, lendo a saída de git status.
Criar branches, mesclar com git merge e resolver um conflito lendo os marcadores no arquivo.
Criar um repositório local, registrar mudanças (git add, git commit) e navegar pelo histórico (git log, git diff, git show).
Escrever um .gitignore, um README.md e um .gitattributes que servem para qualquer projeto das trilhas.
Desfazer trabalho com segurança escolhendo entre git restore, git commit --amend, git reset e git revert.
Autenticar-se no GitHub pelo terminal (gh auth login), criar o repositório remoto (gh repo create), publicar (git push) e abrir, revisar e mesclar um pull request (gh pr create, gh pr merge).
Usar git stash, tags anotadas e um fluxo de trabalho em dupla sem sobrescrever o código do colega.
[ ] Ambiente do Capítulo 01 pronto: git --version responde 2.x.y, code . abre o VS Code e você navega pelo terminal.
[ ] A pasta ~/weblab/site-evento com as cinco páginas do site do evento acadêmico (Nível 1) — ou a pasta do seu projeto autoral, se você está em outra trilha.
[ ] Um e-mail que você acesse, para criar a conta no GitHub.
[ ] O gh (GitHub CLI) instalado — o Passo 4 do passo a passo mostra como, em cada sistema.
No Capítulo 01 você montou a bancada: terminal, VS Code, DevTools, Node e npm. A bancada funciona, mas não tem memória — apagou, apagou; quebrou, quebrou. Hoje você instala essa memória. O Git guarda cada versão do projeto na sua máquina; o GitHub guarda uma cópia na internet, permite trabalhar em dupla e é o endereço público do seu trabalho daqui em diante. No Capítulo 03, esse mesmo repositório vira um site publicado.
Esse esquema falha em quatro pontos, e todos aparecem no semestre: não dá para saber o que mudou entre duas pastas sem abrir as duas e comparar arquivo por arquivo; não dá para desfazer só uma parte (se a versão nova quebrou o menu mas melhorou o rodapé, você perde as duas coisas ao voltar); não dá para trabalhar em dupla sem alguém sobrescrever o outro por WhatsApp; e não dá para publicar, porque nenhuma hospedagem moderna aceita .zip — todas leem um repositório Git.
Um sistema de controle de versão resolve os quatro. O Git registra o projeto inteiro em pontos no tempo (os commits), guarda quem fez cada mudança e por quê, permite voltar a qualquer ponto e permite que várias pessoas mexam nos mesmos arquivos e reconciliem o resultado.
E há um efeito colateral pedagógico que importa mais do que parece: quem versiona experimenta mais. Sem Git, você hesita antes de reescrever o CSS que está "quase bom". Com Git, você cria uma branch, destrói tudo, e volta ao estado anterior com um comando. A coragem para refatorar é uma consequência técnica do versionamento.
Sem Git
Com Git
"Acho que a versão boa é a de terça"
git log mostra data, autor e mensagem de cada versão
Comparar arquivos abrindo os dois; copiar a pasta antes de mexer
git diff mostra linha a linha o que mudou; git switch -c experimento cria uma linha do tempo paralela
Mandar .zip no WhatsApp
git push e o link do repositório
🧠 Você sabia?
Linus Torvalds escreveu a primeira versão do Git em pouco mais de duas semanas, depois que o projeto do kernel Linux perdeu o direito de usar a ferramenta proprietária que vinha usando. O objetivo declarado era ser rápido, distribuído e à prova de corrupção de dados — por isso todo commit é identificado por um hash do próprio conteúdo. Mudou um byte em qualquer arquivo de qualquer commit antigo? O hash muda, e o Git percebe. Não é um recurso de segurança adicionado depois: é o formato de armazenamento.
A maioria dos sistemas antigos guardava, para cada arquivo, a lista de diferenças em relação à versão anterior. O Git guarda fotografias do projeto inteiro (snapshots). A cada commit ele salva o estado de todos os arquivos rastreados; os que não mudaram não são copiados de novo, apenas referenciados.
Cada commit é um objeto com o conteúdo de todos os arquivos naquele instante, o autor, o e-mail e o instante da gravação, a mensagem que você escreveu e o hash do commit anterior (o "pai").
É esse último campo que forma a corrente: cada commit aponta para o anterior, e o histórico é a corrente inteira. O identificador de um commit é um hash de 40 caracteres hexadecimais, mas na prática você usa os 7 primeiros (a3f9c21), que já são únicos em um projeto de porte comum.
Este é o conceito que mais confunde no começo, e o que mais economiza tempo depois. Um arquivo do seu projeto vive em uma de três áreas:
Texto
Diretório de trabalho Área de preparação Repositório
(working tree) (staging area / index) (.git)
os arquivos que você o que você escolheu para o histórico
edita no VS Code entrar no próximo commit permanente
│ ─────── git add ──────────────► │ ──── git commit ───────► │
│ ◄────── git restore ─────────── │ │
│ ◄──────────────── git restore --source=HEAD ───────────── │
O diretório de trabalho é a pasta que você vê no gerenciador de arquivos; editar aqui não registra nada. A área de preparação é uma lista do que vai no próximo commit, e existe para você poder commitar parte do que fez — corrigir o menu em um commit e o rodapé em outro, mesmo tendo feito as duas coisas juntas. O repositório é a pasta oculta .git na raiz do projeto, onde os commits ficam: apagou a .git, apagou o histórico; copiou a .git junto, levou o histórico.
Um arquivo pode estar em quatro estados: não rastreado (o Git nunca o viu), modificado (mudou desde o último commit), preparado (está na área de preparação) e inalterado. O git status diz exatamente em qual estado está cada arquivo — leia-o sempre.
Uma branch é apenas um nome que aponta para um commit — não é uma cópia da pasta. HEAD é um ponteiro para a branch em que você está: git switch outra move o HEAD, e o Git troca o conteúdo da pasta para bater com aquele commit. Criar uma branch é instantâneo, porque só cria um nome; é por isso que o fluxo "uma branch por tarefa" é normal em qualquer time.
🔎 Por baixo do capô
Entre em um repositório e rode ls .git. Você vai ver HEAD (um arquivo de uma linha, com o texto ref: refs/heads/main), objects/ (todo o conteúdo, comprimido e endereçado por hash), refs/heads/ (um arquivo por branch, contendo o hash do commit) e config (as configurações locais). Rode cat .git/HEAD e cat .git/refs/heads/main. O segundo arquivo tem exatamente 41 bytes: o hash do último commit e uma quebra de linha. Uma branch é isso — um arquivo com um hash dentro.
Antes do primeiro commit, diga ao Git quem você é. Essa informação vai junto de cada commit e não pode ser corrigida depois sem reescrever o histórico:
Use o mesmo e-mail da sua conta do GitHub; é assim que o site liga os commits ao seu perfil e conta as contribuições.
Mais quatro ajustes que evitam problemas conhecidos (no Windows, a chave core.autocrlf recebe true em vez de input):
Terminal
gitconfig--globalinit.defaultBranchmain# nome da branch inicial
gitconfig--globalcore.editor"code --wait"# VS Code como editor de mensagens
gitconfig--globalpull.rebasefalse# git pull faz merge (padrão explícito)
gitconfig--globalcore.autocrlfinput# Linux e macOS
gitconfig--list--show-origin# confere tudo
O que é o autocrlf? Windows termina linhas com dois caracteres (CR + LF); Linux e macOS, com um só (LF). Sem configuração, um arquivo salvo no Windows aparece como "todas as linhas mudaram" para quem está no Linux. Com autocrlf, o Git converte na entrada e na saída, e o aviso warning: LF will be replaced by CRLF que você viu no Capítulo 01 passa a ser inofensivo — ele está apenas anunciando a conversão.
O --show-origin mostra cada chave, o valor e de qual arquivo ela veio (~/.gitconfig para o global, .git/config para o do projeto). Configurações do projeto vencem as globais — útil quando um repositório precisa de outro e-mail.
💡 Dica
Apelidos economizam digitação: git config --global alias.s "status -s" e git config --global alias.lg "log --oneline --graph --decorate --all" fazem git s e git lg funcionarem.
Entre na pasta do projeto e transforme-a em repositório:
Terminal
cd~/weblab/site-evento
gitinit
gitstatus
O git init responde Initialized empty Git repository in /home/ana/weblab/site-evento/.git/. Nada mais mudou — os arquivos continuam iguais, apenas ganharam uma pasta .git ao lado. O git status mostra:
Texto
On branch main
No commits yet
Untracked files:
(use "git add <file>..." to include in what will be committed)
contato.html
css/
img/
index.html
inscricao.html
js/
palestrantes.html
programacao.html
Tudo está não rastreado: o Git enxerga os arquivos, mas não cuida deles ainda. Prepare e commite:
Terminal
gitadd.
gitstatus-s
gitcommit-m"Estrutura inicial do site do evento com as cinco páginas"
O git status -s (short) é a versão compacta, de duas colunas: a primeira mostra o estado na área de preparação, a segunda no diretório de trabalho. Aqui ele imprime uma linha A <arquivo> por página. A = adicionado; as outras letras que você vai ver são M (modificado), D (apagado), R (renomeado) e ?? (não rastreado).
4.2 Mensagens de commit que servem para alguma coisa¶
A mensagem é lida por você mesmo daqui a três semanas, procurando quando o menu quebrou. Duas regras bastam:
Imperativo, presente, em português: "Adiciona menu responsivo", não "adicionei" nem "adicionando".
Diga o quê e, se não for óbvio, o porquê. O como já está no diff.
Mensagem ruim
Mensagem boa
alteracoes
Corrige alinhamento do menu no celular
update
Adiciona página de palestrantes com 6 fichas
arrumei o css
Troca cores fixas por variáveis CSS no tema
Para uma mensagem com corpo (título curto, linha em branco, explicação), rode git commit sem -m: o VS Code abre (por causa do core.editor), você escreve, salva e fecha a aba. O Git usa o texto.
Muitos times adotam Conventional Commits, um formato com prefixo: feat: para funcionalidade nova, fix: para correção, docs: para documentação, style: para formatação, refactor: para reorganização sem mudar comportamento e chore: para tarefas de infraestrutura — por exemplo, fix: corrige link quebrado para programacao.html. Não é obrigatório no WebLab, mas adote: o histórico fica legível e você já chega no mercado falando a língua.
gitlog--oneline# uma linha por commit
gitlog--oneline--graph--decorate--all# com o desenho das branches
gitlog--stat# quantas linhas mudaram em cada arquivo
gitlog-3# só os três últimos
gitlog--author="Ana"# filtra por autor
gitlog--css/estilo.css# só os commits que tocaram nesse arquivo
Saída de git log --oneline --graph --decorate --all em um projeto com duas branches:
Texto
* 9c1d2e4 (HEAD -> main) Mescla menu-responsivo em main
|\
| * 4b7a0f1 (menu-responsivo) Fecha o menu ao clicar em um link
| * e21c8ad Adiciona botão hambúrguer e media query
|/
* a3f9c21 Estrutura inicial do site do evento com as cinco páginas
Para ver o conteúdo de um commit específico, git show a3f9c21 (mensagem e diff completo), git show a3f9c21 --stat (só a lista de arquivos) ou git show a3f9c21:index.html (o arquivo como estava naquele commit).
gitdiff# diretório de trabalho × área de preparação
gitdiff--staged# área de preparação × último commit
gitdiffHEAD# diretório de trabalho × último commit (os dois juntos)
gitdiffmainmenu-responsivo# diferença entre duas branches
- é a linha removida, + a adicionada, e as linhas sem sinal são contexto. O @@ -12,7 +12,7 @@ diz que o trecho começa na linha 12 e tem 7 linhas nos dois lados.
🔬 Investigue
Dentro do site-evento, abra o index.html e troque o texto do <h1>. Não salve ainda. Rode git status: nada mudou para o Git (ele lê o arquivo em disco). Agora salve e rode git status de novo — o arquivo aparece como modified. Rode git diff e leia as linhas -/+. Agora git add index.html, e rode git diff outra vez: sai vazio, porque a mudança saiu do diretório de trabalho e foi para a área de preparação. Rode git diff --staged e ela reaparece. Essas duas telas explicam as três áreas melhor do que qualquer diagrama.
Três categorias de arquivo ficam de fora. O regenerável (node_modules/, dist/, build/, .vite/), que qualquer pessoa recria com npm install e npm run build. O secreto (.env, chaves de API, credenciais de banco): um segredo commitado está público para sempre, mesmo que você apague depois, porque continua no histórico. E o que é do seu computador: .DS_Store (macOS), Thumbs.db (Windows), *.log e pastas de configuração pessoal do editor.
O arquivo .gitignore, na raiz, lista o que o Git deve ignorar:
.gitignore
Texto
# Dependências e artefatos de build
node_modules/
dist/
build/
.vite/
# Variáveis de ambiente e segredos
.env
.env.*.local
*.pem
*.key
# Logs e arquivos do sistema operacional
*.log
.DS_Store
Thumbs.db
# Editores (mantemos as configurações compartilhadas do projeto)
.idea/
.vscode/*
!.vscode/settings.json
A sintaxe é simples: uma linha por padrão e # para comentário; barra no fim (dist/) significa "só pastas com esse nome"; * casa qualquer coisa dentro de um nível e **/ casa qualquer profundidade; barra no começo (/temp) ancora na raiz do repositório; e !reverte a regra anterior — é o que faz .vscode/settings.json ser versionado mesmo com .vscode/* ignorado.
⚠️ Atenção
O .gitignore só vale para arquivos não rastreados. Se você já commitou node_modules/, acrescentar a linha não resolve: o Git continua cuidando dele. Remova do rastreamento sem apagar do disco com git rm -r --cached node_modules e commite. E se o que vazou foi um .env com senha, trocar a senha é obrigatório — apagar o arquivo não apaga o histórico.
O GitHub renderiza o README.md na página inicial do repositório. É a primeira coisa que quem avalia o projeto, um colega ou um recrutador vai ler. Cinco seções bastam: o que é, como rodar, estrutura, estado atual e autoria.
README.md
Markdown
# Site do Evento — Semana Acadêmica de Sistemas de Informação
Site institucional de cinco páginas para a Semana Acadêmica, construído na
trilha Nível 1 do WebLab. HTML5 semântico, CSS3 responsivo e
JavaScript sem framework.
Como rodar: clone, abra a pasta no VS Code e clique em **Go Live** (extensão
Live Server) com o `index.html` aberto. O site sobe em `http://127.0.0.1:5500`.
git clone https://github.com/ana-souza/site-evento.git
cd site-evento && code .
Estrutura: `index.html`, `programacao.html`, `palestrantes.html`,
`inscricao.html` e `contato.html` na raiz; estilos em `css/estilo.css`,
scripts em `js/script.js`, imagens em `img/`.
Estado atual: as cinco páginas em HTML semântico e o menu de navegação estão
prontos; faltam o layout responsivo e a validação do formulário de inscrição.
Ana Souza — Introdução ao Desenvolvimento Web,
seu nome. Código sob licença MIT.
Repare que o bloco de comandos dentro do README está indentado com quatro espaços em vez de cercado por crases — é a outra forma de marcar código em Markdown, e ela evita confusão quando o arquivo é mostrado dentro de outro documento. No seu projeto, use títulos ## para separar as cinco seções e uma lista de tarefas (- [x] e - [ ]) no estado atual: o GitHub a renderiza com caixas de seleção.
O core.autocrlf da §3 é uma configuração da sua máquina. O .gitattributes é do projeto, e vale para todo mundo que clonar:
.gitattributes
Texto
# Normaliza o fim de linha de todo arquivo de texto para LF no repositório
* text=auto eol=lf
# Arquivos binários: nunca converter
*.png binary
*.jpg binary
*.webp binary
*.pdf binary
*.woff2 binary
Com isso, o repositório guarda tudo com LF, independentemente de quem commitou, e cada máquina recebe o que precisa ao clonar. É uma linha que evita diffs de mil linhas quando a dupla usa sistemas diferentes.
Quatro situações, quatro comandos. Escolher errado aqui é a principal causa de trabalho perdido no semestre.
"Editei e quero voltar ao último commit." O git restore descarta o que está só no diretório de trabalho — e apaga de vez o que você escreveu e não commitou:
Terminal
gitrestoreindex.html# descarta as mudanças de um arquivo (. = todos)
gitrestore--stagedindex.html# tira da área de preparação, mantendo as edições
"O último commit está errado." Se você errou a mensagem ou esqueceu um arquivo e ainda não deu push, git add arquivo-esquecido.html seguido de git commit --amend -m "nova mensagem" substitui o último commit por um novo, com outro hash. Nunca use em commit que já foi para o GitHub e que outra pessoa possa ter baixado.
"Quero desfazer o commit, mas manter o trabalho."
Terminal
gitreset--softHEAD~1# desfaz o commit; tudo volta para a área de preparação
gitresetHEAD~1# desfaz o commit; tudo volta para o diretório de trabalho
gitreset--hardHEAD~1# desfaz o commit E apaga as mudanças — o perigoso
"Preciso desfazer um commit que já está no GitHub." Use git revert a3f9c21. O revertnão apaga nada: cria um commit novo que desfaz o que aquele commit fez. É o único método seguro quando o histórico já é público, porque não reescreve nada que alguém já baixou — e --amend e reset reescrevem.
⚠️ Atençãogit reset --hard e git restore sem nada preparado são as duas únicas formas comuns de perder trabalho de verdade com Git. Antes de qualquer um dos dois, rode git status e leia a lista do que vai sumir. Se estiver em dúvida, git stash (§10.1) guarda tudo sem apagar.
gitswitch-cmenu-responsivo# cria a branch e já muda para ela
gitswitchmain# volta para main
gitbranch-a# lista as branches locais (* na atual) e as remotas
gitbranch-dmenu-responsivo# apaga (só se já foi mesclada; -D força)
O comando antigo para isso era git checkout -b; ele continua funcionando, mas git switch existe justamente porque checkout fazia coisas demais e confundia. Use switch para branches e restore para arquivos.
Regra de ouro do WebLab: main sempre funciona. Toda mudança nasce em uma branch com nome descritivo (menu-responsivo, validacao-inscricao, corrige-contraste) e só volta para main quando está pronta.
gitswitchmain
gitmergemenu-responsivo
gitmerge--no-ffmenu-responsivo# força sempre um commit de mesclagem
Dois desfechos possíveis. No fast-forward, se main não recebeu nenhum commit desde que a branch nasceu, o Git apenas move o ponteiro de main para frente, sem criar commit novo. No commit de mesclagem, quando as duas branches evoluíram, o Git cria um commit com dois pais — é o 9c1d2e4 do grafo da §4.3. O --no-ff força o segundo caso mesmo quando o primeiro seria possível, e o histórico fica mais legível: cada funcionalidade vira um "nó" visível no grafo.
Leia assim: entre <<<<<<< HEAD e ======= está a versão da branch em que você está; entre ======= e >>>>>>> está a versão da branch que você está trazendo.
Resolver é editar o trecho à mão até ficar como deve ficar — pode ser uma das duas, ou uma terceira redação — e apagar as três linhas de marcador. Depois:
Terminal
gitdiff--name-only--diff-filter=U# lista só os arquivos ainda em conflito
gitaddindex.html
gitcommit# sem -m: o Git já sugere a mensagem de mesclagem
gitmerge--abort# alternativa: desiste e volta ao estado anterior
Quando um arquivo inteiro deve vir de um lado só, git checkout --ours index.html aceita a versão da branch atual e --theirs a que está chegando. O VS Code também mostra os conflitos com botões (Accept Current Change, Accept Incoming Change, Accept Both Changes); use-os, mas leia o resultado: aceitar "both" com frequência gera HTML duplicado.
🧠 Você sabia?
Conflito não é erro, e não é sinal de que alguém fez algo errado. É o Git avisando que uma decisão humana é necessária. Times grandes tratam a frequência de conflitos como métrica de organização: muitos conflitos indicam branches que vivem tempo demais separadas. A receita é a mesma em qualquer time: branches curtas, mescladas em dias e não em semanas, e git pull no começo de cada sessão de trabalho.
Git é o programa que roda na sua máquina e guarda o histórico. GitHub é um site que hospeda repositórios Git e acrescenta o que o Git não tem: interface web, controle de acesso, issues, pull requests, ações automatizadas e páginas publicadas. Existem concorrentes (GitLab, Bitbucket, Codeberg) com as mesmas ideias. Você pode usar Git sem GitHub a vida toda, mas não pode publicar um site, colaborar ou entregar um link sem um servidor remoto.
Crie sua conta em https://github.com usando o mesmo e-mail do git config user.email. Escolha um nome de usuário que você mostraria a um empregador: ele vai virar parte da URL de todos os seus projetos e, no Capítulo 03, do endereço do seu site (ana-souza.github.io).
Enquanto estiver lá, ative a autenticação em duas etapas — o GitHub a exige de quem contribui com código.
O gh é o cliente oficial de linha de comando. Ele resolve a autenticação e permite criar repositórios, abrir pull requests e revisar código sem sair do terminal.
Instalação:
Terminal
sudoaptinstallgh# Ubuntu/Debian
brewinstallgh# macOS, com Homebrew
wingetinstall--idGitHub.cli# Windows, em um PowerShell
Conferir e autenticar com gh --version e gh auth login. O login faz quatro perguntas no terminal:
What account do you want to log into? → GitHub.com
What is your preferred protocol for Git operations? → HTTPS (mais simples; a §8.3 mostra a alternativa)
Authenticate Git with your GitHub credentials? → Yes (o gh passa a responder pelo git push, sem pedir senha)
How would you like to authenticate? → Login with a web browser — o terminal mostra um código de oito caracteres, você o cola na página que abrir e autoriza.
Confira com gh auth status. Saída esperada: ✓ Logged in to github.com account ana-souza (keyring) e a lista de escopos.
💡 Dica
Em uma máquina de laboratório, nunca deixe sua sessão do gh aberta. Faça gh auth logout ao terminar, ou use gh auth login com o navegador em janela anônima. O token guardado dá acesso de escrita a todos os seus repositórios.
Com HTTPS + gh, o push já funciona sem senha. Se quiser chaves SSH (útil quando você também acessa um servidor, como no Capítulo 06), gere a chave aceitando o caminho padrão ~/.ssh/id_ed25519, envie a pública e teste:
Terminal
ssh-keygen-ted25519-C"ana.souza@gmail.com"
ghssh-keyadd~/.ssh/id_ed25519.pub--title"Notebook da Ana"
ssh-Tgit@github.com
Resposta esperada: Hi ana-souza! You've successfully authenticated, but GitHub does not provide shell access. Isso não é erro — o GitHub só aceita Git, não sessão de terminal.
O --public deixa o repositório visível para qualquer pessoa (--private faz o contrário), --source=. usa o repositório Git da pasta atual em vez de criar um vazio, --remote=origin cadastra a URL remota com o apelido origin e --push já envia a branch atual.
Sem o gh, você cria o repositório vazio pela interface web e liga os dois no terminal:
O -u (de upstream) liga a branch local main à remota origin/main. Depois disso, git push e git pull sem argumentos já sabem para onde ir. Para abrir o repositório no navegador, gh repo view --web; em outra máquina, gh repo clone ana-souza/site-evento (ou git clone <url>) traz o histórico inteiro, e não só os arquivos — por isso git log funciona imediatamente na máquina nova, sem internet.
Um pull request (PR) é um pedido: "revisem estas mudanças e, se estiverem boas, coloquem na main". Ele mostra o diff, permite comentários linha a linha, roda verificações automáticas (Capítulo 09) e registra a decisão.
Em um projeto de uma pessoa, o PR parece burocracia — mas é justamente aí que ele ensina: você se obriga a reler o próprio diff antes de mesclar, e o número de bugs bobos cai. Em dupla, ele é o único jeito civilizado de trabalhar. O ciclo completo:
Texto
main ──●──────────────────────────────●── (merge do PR)
\ ●───────●───────●──────────/ branch da tarefa
commit commit push revisão
gitswitch-cmenu-responsivo
gitaddcss/estilo.cssjs/script.jsindex.html
gitcommit-m"Adiciona menu hamburguer responsivo abaixo de 768px"
gitpush-uoriginmenu-responsivo
ghprcreate--basemain--headmenu-responsivo\--title"Menu responsivo com botão hambúrguer"\--body"Abaixo de 768px o menu vira um botão. Fecha ao clicar em um link e ao apertar Esc. Testado no modo dispositivo do DevTools em 375px e 768px."
Três opções que valem conhecer: --fill usa a mensagem do commit como título e corpo, --draft abre como rascunho (ninguém revisa ainda) e --web abre o formulário no navegador. O gh imprime a URL do PR — guarde-a: é o link que você compartilha quando a entrega envolve revisão.
ghprlist# PRs abertos no repositório
ghprview1# descrição, autor, estado
ghprdiff1# o diff completo, no terminal
ghprcheckout1# baixa a branch do PR para testar na sua máquina
ghprchecks1# resultado das verificações automáticas
ghprreview1--comment--body"O botão precisa de aria-expanded."
ghprreview1--approve
ghprreview1--request-changes--body"O menu não fecha com Esc."
Uma revisão útil olha quatro coisas, nesta ordem: funciona? (baixe a branch e teste), está claro? (nomes, indentação), quebra algo? (links, outras páginas), falta alguma coisa? (acessibilidade, tratamento de erro, alt nas imagens).
⚠️ Atenção
O GitHub não deixa você aprovar o seu próprio pull request. Se tentar, o gh responde Can not approve your own pull request. Isso não impede o merge em um repositório pessoal — apenas o carimbo de aprovação. Nos trabalhos em dupla, cada um aprova o PR do outro.
Cria um commit de mesclagem, preserva todos os commits da branch
Quando o histórico da branch conta uma história útil
--squash
Junta todos os commits da branch em um só na main
O padrão do WebLab: main fica com um commit por funcionalidade
--rebase
Reaplica os commits da branch por cima da main, sem commit de mesclagem
Histórico linear, em times que exigem isso
Depois do merge, atualize sua máquina com git switch main e git pull. O --delete-branch do gh apaga as duas cópias da branch — a do GitHub e a da sua máquina —, então não sobra nada para um git branch -d apagar depois. Se você mesclar pelo site, sem o --delete-branch, aí sim: git push origin --delete menu-responsivo remove a remota e git branch -d menu-responsivo remove a local.
No repositório do GitHub, em Settings → Branches → Add branch ruleset, você exige que a main só receba código por PR e só depois de uma aprovação — é o que impede o git push direto no dia do prazo. No Capítulo 09, essa mesma tela passa a exigir que os testes automáticos passem antes do merge.
Você está no meio de uma alteração e precisa trocar de branch agora (o colega achou um bug em produção). Commitar pela metade polui o histórico; perder o trabalho, nem pensar:
Terminal
gitstashpush-u-m"menu pela metade"# -u inclui arquivos não rastreados
gitswitchmain
gitswitchmenu-responsivo# depois de resolver o urgente e commitar
gitstashlist# stash@{0}: On menu-responsivo: menu pela metade
gitstashpop# devolve as mudanças e remove da pilha
Na pilha também valem git stash apply stash@{0} (devolve mantendo o item), git stash show -p stash@{0} (mostra o diff guardado), git stash drop (descarta um) e git stash clear (esvazia tudo).
Uma tag é um nome fixo para um commit. Serve para marcar entregas:
Terminal
gittag-av1.0.0-m"Marco 1: cinco páginas em HTML"
gitpushoriginv1.0.0# tags não sobem no push comum; --tags envia todas
O git tag -l lista as existentes. O -a cria uma tag anotada, que guarda autor, data e mensagem — é a que se usa em entregas. Sem -a, a tag é apenas um apelido do hash. No GitHub, uma tag pode virar uma release, com notas e arquivos anexados:
Terminal
ghreleasecreatev1.0.0--title"Marco 1 — site do evento"\--notes"Cinco páginas em HTML semântico, menu de navegação e formulário de inscrição."
gitreflog# todo movimento do HEAD, inclusive o que "sumiu"
gitblamecss/estilo.css# quem escreveu cada linha e em qual commit
gitbisectstart# busca binária pelo commit que introduziu um bug
gitclean-n# lista arquivos não rastreados que seriam apagados (-fd apaga)
O git reflog é a rede de segurança: mesmo depois de um git reset --hard infeliz, o commit antigo continua listado lá por semanas, e git switch -c recuperado <hash> traz tudo de volta.
O trabalho em dupla (nesta trilha ou em qualquer projeto real) segue sempre o mesmo ciclo:
Antes de começar a trabalhar, atualize: git switch main && git pull.
Crie uma branch por tarefa (git switch -c inscricao-validacao) e faça commits pequenos e frequentes, um assunto por commit.
Publique cedo: git push -u origin inscricao-validacao já no primeiro commit, mesmo inacabado (gh pr create --draft). O colega vê que você está mexendo ali.
Abra o PR, peça revisão e responda aos comentários com novos commits — o PR se atualiza sozinho a cada push.
Merge com squash, apague a branch, volte para a main e dê git pull.
Duas situações inevitáveis:
A main andou enquanto você trabalhava. Traga as novidades para a sua branch antes de pedir o merge, resolvendo os conflitos ali e não na main:
! [rejected] main -> main (fetch first)
error: failed to push some refs to 'https://github.com/ana-souza/site-evento.git'
hint: Updates were rejected because the remote contains work that you do not have locally.
Significa que o remoto tem commits que você não tem. A resposta certa é git pull (que mescla) e depois git push. A resposta errada é git push --force, que apaga o trabalho do colega no servidor.
Combine com a dupla, no primeiro dia: quem mexe em qual arquivo (um no HTML das páginas, outro no CSS, por exemplo), que a main é intocável e só recebe merge de PR, e que ninguém dá --force em branch compartilhada.
🔬 Investigue
Simule uma dupla sozinho, em duas pastas. Clone o mesmo repositório duas vezes: git clone <url> copia-a e git clone <url> copia-b. Em copia-a, mude o <h1> do index.html, commite e dê push. Em copia-b, sem dar pull, mude a mesma linha, commite e tente o push. Leia a mensagem de rejeição inteira. Agora rode git pull em copia-b: o conflito aparece. Resolva, commite e dê push. Volte em copia-a e rode git pull para ver o resultado final. Você acabou de viver, em dez minutos, o ciclo que trava a maioria dos trabalhos em grupo.
🚀 Passo a passo — O site do evento versionado, no GitHub e com um PR mesclado¶
Ao fim destes passos, site-evento é um repositório Git com histórico legível, existe em https://github.com/<seu-usuario>/site-evento, tem README.md, .gitignore e .gitattributes, e uma funcionalidade nova entrou na main por um pull request revisado e mesclado. Faça na ordem.
Passo 1 — Configurar o Git e iniciar o repositório¶
Rode os seis git config --global da §3 (no Windows, core.autocrlf true) e confira com git config --list --show-origin. Depois:
Terminal
cd~/weblab/site-evento
gitinit
gitadd.
gitstatus-s
gitcommit-m"Estrutura inicial do site do evento com as cinco páginas"
gitlog--oneline
Esperado: uma linha, com o hash curto e a mensagem.
Crie os três arquivos na raiz com o conteúdo das §5.1, §5.3 e §5.2 (adaptando o README ao seu projeto e ao seu nome). Depois commite e teste se o .gitignore está valendo:
Terminal
gitadd.gitignore.gitattributesREADME.md
gitcommit-m"Adiciona README, .gitignore e .gitattributes"
mkdir-pnode_modules&&touchnode_modules/teste.js.env
gitstatus-s
rm-rfnode_modules.env
Esperado: o git status -snão menciona node_modules nem .env.
Crie a branch com git switch -c menu-responsivo e acrescente o botão ao menu de index.html, dentro do <header>, antes da lista de links:
index.html
HTML
<headerclass="cabecalho"><aclass="logo"href="index.html">Semana Acadêmica</a><buttonclass="botao-menu"id="botaoMenu"aria-expanded="false"aria-controls="menuPrincipal"aria-label="Abrir menu de navegação">☰</button><nav><ulclass="menu"id="menuPrincipal"><li><ahref="index.html">Início</a></li><li><ahref="programacao.html">Programação</a></li><li><ahref="palestrantes.html">Palestrantes</a></li><li><ahref="inscricao.html">Inscrição</a></li><li><ahref="contato.html">Contato</a></li></ul></nav></header>
css/estilo.css (acrescente ao fim do arquivo)
CSS
/* Menu responsivo: o botão só aparece em telas estreitas */.botao-menu{display:none;font-size:1.5rem;background:none;border:0;color:inherit;cursor:pointer;padding:0.25rem0.5rem;}@media(max-width:768px){.botao-menu{display:block;}.menu{display:none;flex-direction:column;width:100%;gap:0.5rem;padding:1rem0;}.menu.aberto{display:flex;}}
js/script.js (acrescente ao fim do arquivo)
JavaScript
// Menu responsivo: abre e fecha a lista de links em telas estreitasconstbotaoMenu=document.querySelector('#botaoMenu')constmenuPrincipal=document.querySelector('#menuPrincipal')functiondefinirEstado(aberto){menuPrincipal.classList.toggle('aberto',aberto)botaoMenu.setAttribute('aria-expanded',String(aberto))botaoMenu.setAttribute('aria-label',aberto?'Fechar menu de navegação':'Abrir menu de navegação')}botaoMenu.addEventListener('click',()=>definirEstado(!menuPrincipal.classList.contains('aberto')))menuPrincipal.addEventListener('click',(evento)=>{if(evento.target.tagName==='A')definirEstado(false)})document.addEventListener('keydown',(evento)=>{if(evento.key==='Escape')definirEstado(false)})
Teste no navegador (Live Server + modo dispositivo em 375 px) e commite:
Terminal
gitaddindex.htmlcss/estilo.cssjs/script.js
gitcommit-m"Adiciona menu hamburguer responsivo abaixo de 768px"
gitpush-uoriginmenu-responsivo
ghprcreate--basemain--headmenu-responsivo\--title"Menu responsivo com botão hambúrguer"\--body"Abaixo de 768px o menu vira um botão com aria-expanded. Fecha ao clicar em um link e com Esc. Testado no modo dispositivo em 375px e 768px."
ghprlist
ghprdiff
ghprreview--comment--body"Diff conferido: aria-expanded muda junto com a classe, e o Esc fecha. Aprovado para merge."
Se você estiver em dupla, quem não abriu o PR roda gh pr checkout <numero>, testa no navegador e depois gh pr review <numero> --approve.
ghprmerge--squash--delete-branch
gitswitchmain
gitpull
gitlog--oneline--graph--decorate--all
gittag-av1.0.0-m"Site do evento com as cinco páginas e menu responsivo"
gitpushoriginv1.0.0
ghreleasecreatev1.0.0--title"v1.0.0 — site do evento"\--notes"Cinco páginas em HTML semântico, CSS com menu responsivo e navegação por teclado."
ghrepoview--web
Esperado: o histórico da main mostra três commits, o último com a mensagem do PR, e a release aparece na lateral da página do repositório.
A1. Um arquivo aparece em git status sob Changes not staged for commit. Em qual das três áreas ele está? E se aparecer sob Changes to be committed? E sob Untracked files?
A2. Preveja a saída. Você editou index.html e css/estilo.css, rodou git add index.html e em seguida git diff e git diff --staged. Qual arquivo aparece em cada um dos dois comandos, e por quê?
A3. Para cada situação, diga qual comando usar: (a) descartar uma edição não commitada; (b) tirar um arquivo da área de preparação; (c) corrigir a mensagem do último commit local; (d) desfazer um commit que já está no GitHub; (e) guardar o trabalho pela metade para trocar de branch.
A4. Você acrescentou node_modules/ ao .gitignore, mas a pasta continua aparecendo em git status. Explique por quê e escreva a sequência de comandos que resolve sem apagar a pasta do disco.
A5. Um index.html em conflito tem <<<<<<< HEAD, <h1>Semana Acadêmica</h1>, =======, <h1>Semana Acadêmica de Sistemas de Informação</h1> e >>>>>>> titulo-longo, nesta ordem. Diga qual versão pertence a qual branch, e o que precisa ser feito no arquivo antes do git add.
A6. Qual a diferença entre git fetch e git pull? E entre gh pr merge --squash e gh pr merge --merge, do ponto de vista do que aparece em git log --oneline da main?
B1. Histórico limpo em três commits. Comece de uma pasta nova (git init), com um index.html de esqueleto. Faça exatamente três commits, nesta ordem: um só com o esqueleto HTML, um só com o CSS ligado por <link>, e um só com o <script> e o script.js. As três mensagens devem estar no imperativo e ter menos de 60 caracteres.
Resultado esperado: git log --oneline mostra três linhas; git show --stat de cada commit lista apenas os arquivos daquele assunto (nenhum commit toca em arquivo de outro assunto).
Dica
Faça a edição, git addsó do arquivo daquele assunto e git commit. Se você editar tudo antes, ainda dá certo: use git add <arquivo> seletivamente, ou git add -p para escolher trecho por trecho dentro do mesmo arquivo.
B2. Conflito provocado e resolvido. No site-evento, crie duas branches a partir da main: titulo-curto e titulo-longo. Em cada uma, altere a mesma linha do <h1> do index.html e commite. Volte para a main, mescle a primeira (vai passar), e mescle a segunda (vai dar conflito). Resolva escolhendo uma terceira redação, diferente das duas.
Resultado esperado: git log --oneline --graph --all mostra o desenho com as duas branches e o commit de mesclagem; o index.html final tem a terceira redação e nenhum marcador <<<<<<<.
Dica
git diff --name-only --diff-filter=U lista os arquivos que ainda estão em conflito. Depois de editar, git add marca como resolvido, e git commit sem -m aceita a mensagem que o Git já preparou. Se quiser desistir no meio, git merge --abort.
B3. Arqueologia do repositório. Escolha um repositório público conhecido (por exemplo https://github.com/vuejs/core), clone-o e responda, só pelo terminal: quantos commits ele tem; quem são os cinco maiores autores por número de commits; qual foi o primeiro commit (hash, data e mensagem); qual arquivo tem mais commits no histórico.
Resultado esperado: um arqueologia.md com as quatro respostas e o comando exato usado em cada uma.
Dica
git rev-list --count HEAD conta commits. git shortlog -sn | head -5 ranqueia autores. git log --reverse --oneline | head -1 acha o primeiro. Para o arquivo mais commitado, combine git log --name-only --pretty=format: com sort | uniq -c | sort -rn | head.
B4. Fluxo completo em dupla. Com um colega (ou com duas cópias clonadas, como na §11): a pessoa A adiciona o repositório à conta dela e convida a B em Settings → Collaborators. Cada uma cria uma branch, faz uma alteração em página diferente, abre um PR e revisa e aprova o PR da outra. Ao final, as duas alterações estão na main.
Resultado esperado: dois PRs mesclados, cada um com uma revisão de aprovação de outra pessoa; git log --oneline na main mostra as duas funcionalidades.
Dica
gh pr checkout <numero> baixa a branch do PR para testar antes de aprovar. Lembre que ninguém aprova o próprio PR — se aparecer Can not approve your own pull request, você está tentando revisar o seu.
C1. Resgate depois do desastre. Prepare o cenário: em um repositório de teste com cinco commits, rode git reset --hard HEAD~3. Os três últimos commits sumiram do git log. Recupere-os sem clonar de novo e sem usar o GitHub, deixando a main exatamente como estava antes. Depois, repita o desastre de outra forma: crie uma branch experimento, commite algo nela, volte para a main e apague a branch com git branch -D experimento. Recupere o commit perdido.
Dica
git reflog lista todo movimento do HEAD, incluindo os commits que nenhuma branch aponta mais. A partir de um hash de lá, git reset --hard <hash> devolve a main ao ponto certo, e git switch -c recuperado <hash> cria uma branch nova sobre um commit órfão. Objetos órfãos são apagados de verdade só quando o git gc roda, semanas depois.
O git status diz que a pasta está limpa, mas a .git tem centenas de arquivos. O que exatamente está lá dentro, e por que um repositório com 20 commits de um site de 200 KB pode ocupar menos espaço do que os próprios arquivos? Hoje você abre a caixa preta.
Critérios de pronto
Um relatorio-git.md com o tamanho de .git (du -sh .git) e o número de arquivos, medidos depois de 3, 10 e 20 commits no site-evento.
A saída de cat .git/HEAD e de cat .git/refs/heads/main, com uma frase explicando o que cada uma significa, e o conteúdo do último commit lido diretamente do banco de objetos, com o comando usado.
Três linhas explicando por que copiar a pasta do projeto sem a .git entrega os arquivos mas destrói o histórico.
Pistas
git cat-file -t <hash> diz o tipo de um objeto (commit, tree, blob); git cat-file -p <hash> mostra o conteúdo.
O hash do último commit está em .git/refs/heads/main. Passe-o para o cat-file -p e siga o campo tree.
git count-objects -vH resume quantos objetos existem e quanto ocupam, soltos e empacotados.
Rode git gc e meça de novo: o Git compacta os objetos soltos em um packfile e guarda apenas as diferenças entre versões parecidas.
Pegue o repositório mais bagunçado que você tem (aquele com commits chamados "alteracoes", "update2" e "agora vai") e transforme-o em um repositório que você mostraria numa entrevista. O objetivo não é maquiar: é aprender a escrever o histórico enquanto trabalha, praticando em um caso ruim.
Critérios de pronto
Um repositório novo no GitHub, público, com o mesmo conteúdo final e um histórico de pelo menos 8 commits, cada um com um assunto único e mensagem no formato Conventional Commits.
README.md completo: o que é, como rodar, estrutura de pastas, estado atual em checklist e autoria.
.gitignore e .gitattributes adequados ao tipo de projeto.
Pelo menos uma branch mesclada por pull request, com descrição de três linhas explicando a mudança.
Uma tag anotada v1.0.0, uma release com notas e um parágrafo no README dizendo o que você faria diferente desde o começo.
Pistas
Não tente reescrever o histórico antigo. Comece um repositório limpo e reconstrua o projeto em etapas, commitando cada etapa — é mais rápido e ensina mais.
git add -p permite preparar só parte de um arquivo, o que é o que torna possível separar assuntos que você fez juntos.
Para o PR, escolha uma melhoria real que ainda falta (acessibilidade, responsividade, tratamento de erro) em vez de inventar uma mudança cosmética.
gh repo view --web e leia a sua própria página como se fosse outra pessoa: dá para rodar o projeto só com o que está escrito ali?
Um site que funcionava parou de funcionar, e ninguém sabe quando. Em vez de ler cinquenta commits, o Git faz uma busca binária: você diz um ponto bom e um ruim, e ele te leva ao commit exato em oito passos. Hoje você planta o bug, esquece onde ele está e usa o git bisect para achá-lo.
Critérios de pronto
Um repositório com no mínimo 20 commits no site-evento (ou no seu projeto autoral), com mudanças reais e pequenas.
Um bug plantado em algum commit do meio, que quebre algo verificável por um comando (por exemplo, um seletor de CSS renomeado que faz o menu sumir, ou uma chamada a uma função que não existe).
Um script verificar.sh que sai com código 0 quando o site está bom e diferente de 0 quando está quebrado.
A sessão completa de git bisect run ./verificar.sh colada em um caca.md, mostrando quantos passos foram necessários e qual commit foi apontado.
Uma comparação escrita: quantos commits você teria conferido na mão (busca linear) e quantos o bisect conferiu; a explicação do porquê da diferença.
O bug corrigido por um git revert do commit culpado, e não por uma edição manual.
Este tipo de investigação é o que separa quem entende Git de quem só decora comandos — vale a pena incluir no seu projeto autoral.
Pistas
git bisect start, git bisect bad (no commit atual) e git bisect good <hash-antigo> iniciam a busca; o Git faz o checkout do meio e espera o seu veredicto.
git bisect run <comando> automatiza tudo: o Git roda o comando em cada passo e usa o código de saída como resposta.
Um verificar.sh simples pode ser um grep -q '\.menu' css/estilo.css — sai 0 se achar, 1 se não. Verificações mais sérias entram no Capítulo 10, com testes de verdade.
Com 20 commits, a busca linear conferiria até 20; a binária confere no máximo 5 (o log na base 2 de 20, arredondado para cima). Registre os dois números.
git bisect reset devolve você à branch de onde saiu — não esqueça, ou vai continuar em estado destacado.
🔥
🔥 Boss — A bancada inteira, versionada e publicada¶
gitgithubterminalprojeto
Este é o Boss da Unidade 1: ele junta tudo o que os Capítulos 01 e 02 ensinaram. A ideia é simples de enunciar e trabalhosa de executar bem: uma máquina nova deve ficar pronta para trabalhar rodando um comando do seu repositório. Ambiente, configurações, projeto e histórico, tudo versionado e reproduzível.
Critérios de pronto
Um repositório público bancada no GitHub contendo: extensoes.txt do VS Code, settings.json, .prettierrc, .editorconfig, .gitconfig de exemplo (sem o seu e-mail real) e um instalar.sh.
bash instalar.sh em uma máquina limpa (ou em um usuário novo do seu sistema) instala as extensões, copia as configurações para o caminho certo do sistema detectado e aplica os git config --global da §3. Rodar duas vezes não dá erro nem duplica nada.
Um novo-projeto.sh que cria um projeto do zero (pastas, index.html com lang="pt-BR" e viewport, css/, js/, .gitignore, .editorconfig, README.md), roda git init, faz o primeiro commit e cria o repositório remoto com gh repo create, tudo em um comando.
O histórico do bancada tem no mínimo 6 commits com mensagens no imperativo, uma branch mesclada por pull request e uma tag anotada v1.0.0.
O README.md documenta cada script, mostra a saída esperada, traz uma seção "Testado em" com pelo menos dois sistemas (ou dois usuários da mesma máquina) e três capturas mostrando a máquina limpa antes e depois.
Um colega clona o seu bancada, roda os scripts e confirma no README.md (por pull request) que funcionou na máquina dele.
Pistas
case "$(uname -s)" in Linux*) … ;; Darwin*) … ;; MINGW*|MSYS*) … ;; esac detecta o sistema; os caminhos do settings.json estão no desafio de dotfiles do Capítulo 01.
Idempotência vem de mkdir -p, cp -f e de checar antes de agir: git config --global --get user.name devolve vazio quando a chave não existe.
gh repo create "$1" --public --source=. --remote=origin --push fecha o novo-projeto.sh — mas confira antes se gh auth status está autenticado, e avise com uma mensagem clara se não estiver.
Para o teste "máquina limpa" sem formatar nada, crie um usuário novo no sistema (sudo adduser teste) e rode os scripts lá dentro.
O pull request do colega é a prova de que o processo funciona fora da sua cabeça — é exatamente para isso que a revisão existe.
Leve o seu projeto autoral (o site com o tema que você escolheu na sua trilha) para o GitHub, com histórico decente:
Rode git init na pasta do projeto autoral e crie .gitignore, .gitattributes e README.md (com as seções: o que é, como rodar, estrutura, estado atual em checklist e autoria).
Faça no mínimo quatro commits separados por assunto, com mensagens no imperativo e menos de 60 caracteres cada.
Publique com gh repo create <nome> --public --source=. --remote=origin --push.
Crie a branch melhoria-<algo>, faça uma melhoria real (acessibilidade, responsividade, um texto que estava faltando), commite, dê push e abra um pull request com descrição de três linhas explicando o que mudou e por quê.
Mescle o PR com --squash --delete-branch, volte para a main, dê git pull e crie a tag anotada v0.1.0.
Critério de pronto:git log --oneline na main mostra cinco ou mais commits com mensagens legíveis; o repositório é público; o README aparece renderizado na página; existe um PR com estado MERGED; a tag v0.1.0 está no GitHub; git status diz working tree clean.
Guarde no seu repositório: o link do repositório público e o link do pull request mesclado. Nada de .zip daqui em diante.
[ ] git log --oneline --graph --decorate --all no site-evento mostra um histórico com pelo menos três commits e o traço da branch mesclada.
[ ] git status responde nothing to commit, working tree clean.
[ ] https://github.com/<seu-usuario>/site-evento abre, é público e mostra o README.md renderizado.
[ ] Criar um arquivo .env na pasta não faz nada aparecer em git status.
[ ] gh pr list --state merged lista o pull request do menu responsivo.
[ ] git branch -a mostra só main e remotes/origin/main — as branches de tarefa foram apagadas depois do merge.
[ ] A tag v1.0.0 existe local (git tag -l) e no GitHub (na lateral da página do repositório).
[ ] Você explica, sem consultar, a diferença entre git restore, git reset e git revert, e sabe o que fazer quando aparece CONFLICT (content): Merge conflict in ….
Pro Git, de Scott Chacon e Ben Straub — https://git-scm.com/book/pt-br/v2 — livro oficial, gratuito e em português. Leia os capítulos 2 (Fundamentos) e 3 (Ramificação), que cobrem tudo deste capítulo com mais profundidade.
Documentação de referência do Git — https://git-scm.com/docs — a página de cada comando; comece por git-status, git-switch e git-restore. GitHub Docs em português — https://docs.github.com/pt — contas, repositórios, colaboração e a seção "Sobre pull requests".
GitHub Skills — https://skills.github.com — cursos interativos curtos, feitos dentro de repositórios reais; o "Introduction to GitHub" leva menos de uma hora.
MILETTO, Evandro M.; BERTAGNOLLI, Silvia C. Desenvolvimento de software II. Bookman — capítulo sobre gerência de configuração e controle de versão.
No próximo capítulo, esse repositório deixa de ser só um backup: o GitHub Pages e a Netlify passam a servir o site-evento e o Café Cerrado em endereços públicos, e você descobre por que um href="/css/estilo.css" que funciona na sua máquina quebra no ar.
Explicar o que uma hospedagem estática faz (e o que ela não faz), por que ela é rápida e por que é gratuita para projetos como os seus.
Publicar um repositório no GitHub Pages pelas configurações do site e pelo terminal (gh api), e ler o estado da publicação sem sair do terminal.
Distinguir caminho relativo ao documento, relativo à raiz e absoluto, prever para onde cada um resolve e consertar um site que perde o CSS ao ser publicado em um subcaminho como usuario.github.io/site-evento/.
Colocar favicon, ícone de tela inicial e site.webmanifest em um site publicado em subcaminho, sem depender do pedido automático que o navegador faz à raiz do domínio.
Criar uma página 404.html que funciona em qualquer profundidade de URL, no GitHub Pages e na Netlify.
Publicar na Netlify por arrastar-e-soltar e a partir do Git, com um netlify.toml que define diretório publicado, redirecionamentos e cabeçalhos.
Comparar GitHub Pages, Netlify, Vercel e Cloudflare Pages e justificar a escolha para um projeto específico.
Ler cache-control, etag e age com curl -I, explicar por que a versão antiga insiste em aparecer e resolver com recarga forçada e nomes versionados.
Rodar o Lighthouse contra a URL pública e transformar o relatório em uma lista de correções concretas.
[ ] Node.js 22 LTS instalado, para rodar o Lighthouse com npx no fim do capítulo.
No Capítulo 02 o site-evento virou um repositório com histórico legível, um pull request mesclado e uma tag v1.0.0 — mas ele ainda só existe na sua máquina e em um repositório que só desenvolvedores sabem ler. Hoje ele ganha um endereço que você pode mandar para qualquer pessoa. E, no caminho, você resolve os dois problemas que o Capítulo 01 prometeu resolver aqui: o favicon.ico com 404 no console e a diferença entre caminho absoluto e relativo, que é a causa número um de "publiquei e o site ficou sem CSS".
Um site estático é uma pasta de arquivos: HTML, CSS, JavaScript, imagens, fontes. Quando alguém pede https://ana-souza.github.io/site-evento/programacao.html, o servidor procura o arquivo programacao.html dentro da pasta publicada e devolve os bytes. Ele não executa nada seu, não consulta banco, não decide nada. O JavaScript existe, mas roda no navegador de quem visita, não no servidor.
Isso é uma limitação enorme e, ao mesmo tempo, a razão de tudo o que vem a seguir ser fácil:
Consequência
Por quê
É rápido
Devolver um arquivo já pronto custa quase nada; não há código para rodar antes
É barato (grátis, nos seus projetos)
Copiar arquivos para servidores é a operação mais barata que existe na internet
Escala sozinho
Dez ou dez mil visitantes pedem o mesmo arquivo; o servidor só repete a resposta
Não quebra sozinho
Não há processo para cair, memória para vazar, conexão de banco para expirar
É difícil de invadir
Não há código seu no servidor para explorar
E o que ele não faz: guardar uma inscrição enviada por formulário, verificar uma senha, listar produtos de um banco, esconder uma chave de API. Tudo isso exige um processo rodando do lado do servidor — é o assunto do Capítulo 05. Por enquanto, se o seu formulário de inscrição precisa de fato receber respostas, a saída honesta é apontar o action para um serviço de formulários (a Netlify tem um embutido) ou usar mailto:.
1.2 CDN: por que o site fica perto de quem acessa¶
As quatro plataformas deste capítulo não guardam o seu site em um servidor só. Elas copiam os arquivos para uma CDN (Content Delivery Network): uma rede de servidores espalhados pelo mundo, com pontos de presença em várias cidades. Quem acessa de Sinop é atendido por um servidor perto de Sinop; quem acessa de Lisboa, por um perto de Lisboa.
Texto
você faz a plataforma copia para
git push ──► build ──► dezenas de servidores (CDN)
│ │ │
São Paulo Miami Frankfurt
│ │ │
visitante visitante visitante
O ganho é de latência, não de banda: cada ida e volta até um servidor a 8.000 km custa uns 200 ms, e uma página faz várias idas e voltas. É por isso que um site estático bem publicado costuma abrir em menos de um segundo, mesmo em 4G.
🧠 Você sabia?
O GitHub Pages nasceu em 2008 e, até hoje, roda por padrão um gerador de sites chamado Jekyll, escrito por Tom Preston-Werner — um dos fundadores do próprio GitHub — para publicar o blog dele. É por isso que o GitHub Pages tem manias estranhas de gerador de blog: pastas cujo nome começa com _ recebem tratamento especial e podem sumir do site publicado. A §2.4 mostra o arquivo de uma linha que desliga isso.
No Capítulo 01 você abriu o site com o Live Server, em http://127.0.0.1:5500/site-evento/index.html. Na publicação, quatro coisas mudam — e todas viram bug se você não souber:
A raiz do site muda. No Live Server a raiz é a pasta que você abriu no VS Code. No GitHub Pages de projeto, o site vive dentro de /site-evento/, e a raiz do domínio pertence a outro site. É a §3 inteira.
O sistema de arquivos diferencia maiúsculas. Windows e macOS, por padrão, tratam Logo.png e logo.png como o mesmo arquivo. O servidor Linux da CDN não. Um <img src="img/Logo.png"> que funciona na sua máquina devolve 404 no ar.
Existe cache. O Live Server manda o navegador nunca guardar nada. A CDN faz o contrário: guarda tudo o que puder, por minutos ou meses. É a §8.
Só vai para o ar o que está no Git. Se img/logo.png está no .gitignore, ou se você esqueceu de commitar, ele simplesmente não existe do lado de lá.
🔬 Investigue
Rode curl -I https://weblab.aprendabit.com e curl -I https://github.com e compare os cabeçalhos das duas respostas. Procure server, cache-control, content-type e etag. Depois rode curl -I https://weblab.aprendabit.com/pagina-que-nao-existe e anote o código de status. O -I pede só os cabeçalhos (método HEAD) — é a forma mais rápida de saber quem está servindo um site e como ele manda o navegador guardar as respostas.
O GitHub Pages publica o conteúdo de um repositório em um endereço *.github.io. Existem dois formatos, e a diferença entre eles é a origem da maior parte dos problemas deste capítulo:
Tipo
Nome do repositório
URL publicada
Site de usuário
exatamente <usuario>.github.io
https://<usuario>.github.io/
Site de projeto
qualquer outro nome
https://<usuario>.github.io/<repositorio>/
Você tem um site de usuário por conta e quantos sites de projeto quiser. O site-evento é um site de projeto: ele vai morar em https://ana-souza.github.io/site-evento/ — dentro de um subcaminho, e não na raiz do domínio.
Guarde isso: o seu site não está na raiz. A §3 existe por causa desta frase.
Abra o repositório no GitHub (gh repo view --web faz isso do terminal).
Settings (aba do topo, à direita) → Pages (menu da esquerda, seção Code and automation).
Em Build and deployment → Source, escolha Deploy from a branch.
Em Branch, escolha main e a pasta / (root). Clique em Save.
Espere. Volte para a aba Actions do repositório: existe uma execução chamada pages build and deployment. Quando ela ficar verde, o site está no ar.
A caixa de Branch oferece duas pastas: / (root) e /docs. Use / (root) quando o index.html está na raiz do repositório — é o caso do site-evento. Use /docs quando o site é só uma parte de um repositório maior (documentação de uma API, por exemplo).
A opção GitHub Actions, no mesmo menu Source, serve para sites que precisam ser construídos antes de publicar — um projeto Vite do Nível 3, por exemplo, em que o que vai ao ar é a pasta dist/, e não os fontes. Esse é o assunto do Capítulo 09; aqui, o site já está pronto no repositório.
⚠️ Atenção
O GitHub Pages é gratuito para repositórios públicos. Em repositório privado, ele exige uma conta paga. Neste projeto o repositório é público de qualquer jeito, então isso não te afeta — mas não coloque no site-evento nada que você não mostraria a qualquer pessoa. Publicar é publicar.
Tudo o que o painel faz, a API do GitHub também faz — e o gh api fala com ela. Dentro da pasta do repositório, os marcadores {owner} e {repo} são preenchidos sozinhos com o repositório atual:
Terminal
# Liga o Pages, servindo a raiz da branch main
ghapi--methodPOSTrepos/{owner}/{repo}/pages\-f"source[branch]=main"\-f"source[path]=/"
A resposta é um JSON com a configuração criada. Para acompanhar o estado sem abrir o navegador:
Terminal
# Estado atual, URL pública e origem da publicação
ghapirepos/{owner}/{repo}/pages--jq'{status: .status, url: .html_url, branch: .source.branch}'# Última construção: status e mensagem de erro, se houver
ghapirepos/{owner}/{repo}/pages/builds/latest--jq'{status: .status, erro: .error.message}'
O campo status passa por building e termina em built. Se der errored, a mensagem em .error.message diz o motivo (quase sempre uma sintaxe que o Jekyll não engoliu — veja a §2.4).
Se você mudar a configuração depois, o verbo é PUT, não POST:
Terminal
# Trocar a pasta publicada de / para /docs
ghapi--methodPUTrepos/{owner}/{repo}/pages\-f"source[branch]=main"\-f"source[path]=/docs"# Pedir uma reconstrução manual (útil quando a publicação travou)
ghapi--methodPOSTrepos/{owner}/{repo}/pages/builds
Saber fazer isso pelo terminal não é frescura: é o que permite automatizar a criação de um projeto inteiro em um script, como no Boss do Capítulo 02, e é a mesma API que o GitHub Actions usa no Capítulo 09.
Jekyll come pastas com _. Por padrão, o Pages processa o site com o Jekyll antes de publicar, e o Jekyll trata pastas iniciadas por sublinhado como pastas de trabalho dele — elas não vão para o site. Um projeto Vite gera _assets/ ou nomes parecidos, e o resultado é um site sem CSS e sem JS. A solução é um arquivo vazio na raiz do site:
Terminal
touch.nojekyll
gitadd.nojekyll
gitcommit-m"Desliga o processamento Jekyll no GitHub Pages"
gitpush
Crie esse arquivo sempre. Ele não atrapalha nada e evita uma tarde de depuração.
O index.html manda. Se não existe index.html na pasta publicada, o Pages mostra o README.md renderizado — e o estudante jura que "publicou errado". Não publicou: publicou uma pasta sem página inicial.
Existem limites, e eles são suaves. O site publicado deve ficar abaixo de 1 GB, a banda mensal recomendada é de 100 GB e há um limite de dez construções por hora. Nenhum projeto de estudo chega perto disso, mas vale saber que existe — e que a documentação oficial é a fonte a conferir, porque os números mudam.
📌 Vale gravar
"Um site estático pode ter formulário de login?" Não com validação de verdade: qualquer verificação feita em JavaScript no navegador é lida e burlada por quem abre o DevTools. Autenticação exige servidor. Um site estático pode ter o formulário; a verificação precisa acontecer do outro lado.
3. Caminhos absolutos e relativos: o bug que aparece só depois de publicar¶
Este é o problema anunciado no Capítulo 01, e ele merece a seção mais longa deste capítulo. O sintoma é sempre o mesmo: na sua máquina o site está perfeito; publicado, ele aparece sem estilo, sem imagens e sem JavaScript, como um documento de texto dos anos 90.
A confusão de nome atrapalha: muita gente chama /css/estilo.css de "caminho absoluto". Ele é absoluto dentro do domínio — e é exatamente por isso que ele quebra quando o domínio não é seu inteiro.
Na sua máquina, com o Live Server servindo a pasta site-evento como raiz, /css/estilo.css resolve para http://127.0.0.1:5500/css/estilo.css e funciona. É a mesma linha, com dois destinos diferentes. Por isso o bug só aparece depois do git push.
A regra prática para sites publicados em subcaminho é curta: nada de barra no começo. Use caminhos relativos ao documento em todo o HTML, no CSS e no JavaScript.
🔬 Investigue
Abra o site-evento no Live Server. No index.html, troque href="css/estilo.css" por href="/css/estilo.css" e recarregue: continua funcionando. Agora abra o DevTools → Network, recarregue e clique na linha do estilo.css: leia a Request URL completa. Some /site-evento mentalmente à frente do host e você acaba de simular o que vai acontecer no ar. Desfaça a mudança.
Existe uma armadilha extra, e ela pega gente experiente. Compare:
Texto
https://ana-souza.github.io/site-evento/ → a pasta é /site-evento/
https://ana-souza.github.io/site-evento → a "pasta" é /
Sem a barra final, o navegador considera que site-evento é um arquivo e resolve css/estilo.css como /css/estilo.css. O GitHub Pages e a Netlify normalmente redirecionam a URL sem barra para a URL com barra (301), e o problema desaparece — mas um servidor mal configurado não redireciona, e aí você tem um site quebrado só quando o link vem sem a barra.
Duas consequências práticas: ao divulgar o endereço, inclua a barra final; e, ao linkar uma pasta dentro do seu site, escreva <a href="blog/">, não <a href="blog">.
Caminho relativo ao documento depende da profundidade da página. Se você criar blog/post-1.html, o css/estilo.css escrito lá dentro resolve para /site-evento/blog/css/estilo.css — que não existe. De dentro de uma subpasta, o caminho correto é ../css/estilo.css.
Enquanto o site é plano (todas as páginas na raiz, como o site-evento), isso não aparece. Quando o site cresce, há três saídas:
Contar os ../ em cada página. Funciona, é chato e quebra quando você move um arquivo.
Usar <base>. Uma única tag no <head> redefine a base de todos os caminhos relativos daquele documento:
Com essa <base>, css/estilo.css resolve para /site-evento/css/estilo.css de qualquer profundidade. O preço: a <base> afeta tudo, inclusive links de âncora (href="#a03-programacao" passa a apontar para /site-evento/#programacao, o que muda a página) e requisições de JavaScript. Use com consciência, e sempre com a barra final.
Deixar o construtor resolver. É o que Vite e companhia fazem: você declara a base uma vez e a ferramenta reescreve todos os caminhos no build.
vite.config.js
JavaScript
import{defineConfig}from'vite'exportdefaultdefineConfig({// O site será servido em https://<usuario>.github.io/unieventos-web/// Sem esta linha, o Vite gera caminhos começando com / e o site publica quebrado.base:'/unieventos-web/'})
Também dá para passar a base na linha de comando, sem tocar no arquivo — é o que o workflow do Capítulo 09 faz:
Terminal
npmrunbuild----base=/unieventos-web/
E, no código Vue/JS do projeto, o valor fica disponível em import.meta.env.BASE_URL, para montar caminhos de imagens dinâmicas sem chutar prefixo.
💡 Dica
Antes de publicar, cace os caminhos com barra inicial em todo o projeto de uma vez:
Cada linha que aparecer é um candidato a 404 no ar. As exceções legítimas são URLs completas (https://…), uma <base href> deliberada e os caminhos dentro do 404.html, explicados na §5.
O servidor da CDN roda Linux e diferencia maiúsculas de minúsculas. img/Logo.png e img/logo.png são dois arquivos distintos para ele — mas o mesmo arquivo para o Windows e para o macOS com formatação padrão. Resultado: imagem que aparece na sua máquina e some no ar, sem erro nenhum além de um 404 no console.
Padronize tudo em minúsculas, sem acento e sem espaço: img/logo-semana-academica.png. Para corrigir um arquivo já versionado em um sistema que ignora maiúsculas, o Git precisa de dois passos, senão ele não percebe a mudança:
Terminal
gitmvimg/Logo.pngimg/temporario.png
gitmvimg/temporario.pngimg/logo.png
gitcommit-m"Padroniza o nome do logo em minusculas"
4. Favicon: o 404 que o Capítulo 01 deixou pendente¶
Todo navegador, ao abrir qualquer página, pede automaticamente /favicon.icona raiz do domínio — mesmo que você nunca tenha escrito uma linha sobre isso. No Live Server, isso vira um 404 inofensivo no console. No GitHub Pages de projeto, é pior: o pedido vai para https://ana-souza.github.io/favicon.ico — a raiz do domínio, que pertence ao site de usuário, e não ao seu repositório de projeto. Colocar o favicon.ico na raiz do repositório não faz o navegador encontrá-lo sozinho nesse caso.
A solução é declarar o ícone explicitamente com <link rel="icon">, usando caminho relativo. Aí o navegador para de adivinhar.
Três arquivos cobrem tudo o que importa hoje: um .ico de 32×32 para navegadores antigos e para a aba, um SVG que escala em qualquer tamanho, e um PNG de 180×180 para o ícone de tela inicial do iOS.
index.html (dentro do <head>, nas cinco páginas)
HTML
<head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>Semana Acadêmica de Sistemas de Informação</title><!-- Ícone clássico, para a aba e para navegadores antigos --><linkrel="icon"href="favicon.ico"sizes="32x32"><!-- Ícone vetorial: escala perfeito em qualquer densidade de tela --><linkrel="icon"href="img/favicon.svg"type="image/svg+xml"><!-- Ícone do atalho na tela inicial do iOS (180x180) --><linkrel="apple-touch-icon"href="img/apple-touch-icon.png"><!-- Metadados de aplicativo: nome curto, cores e ícones grandes --><linkrel="manifest"href="site.webmanifest"><metaname="theme-color"content="#0b3d2e"><linkrel="stylesheet"href="css/estilo.css"></head>
Repare que nenhum caminho começa com barra. É a regra da §3 aplicada aos ícones.
site.webmanifest
JSON
{"name":"Semana Acadêmica de Sistemas de Informação","short_name":"Semana Acadêmica","start_url":"./","display":"standalone","background_color":"#ffffff","theme_color":"#0b3d2e","icons":[{"src":"img/icone-192.png","sizes":"192x192","type":"image/png"},{"src":"img/icone-512.png","sizes":"512x512","type":"image/png"}]}
Os caminhos dentro do manifesto são resolvidos em relação ao próprio manifesto, e não à página. Como o site.webmanifest está na raiz do site, img/icone-192.png funciona; start_url: "./" mantém o atalho apontando para o subcaminho certo.
Para gerar os PNG e o .ico a partir de uma imagem quadrada de pelo menos 512 pixels, o ImageMagick resolve em três linhas (Ubuntu: sudo apt install imagemagick):
Sem ImageMagick, o https://realfavicongenerator.net faz o mesmo pelo navegador e entrega um .zip com todos os tamanhos e o trecho de HTML pronto.
🧠 Você sabia?
O favicon.ico é uma herança direta do Internet Explorer 5, de 1999. A Microsoft inventou o recurso para mostrar um ícone quando o usuário adicionava a página aos favoritos — daí o nome, favorite icon. O formato .ico é da própria Microsoft e guarda várias resoluções dentro do mesmo arquivo. A convenção de pedir /favicon.ico automaticamente pegou tão bem que, um quarto de século depois, todo navegador do planeta ainda faz esse pedido em toda página que abre — inclusive na sua.
Quando alguém digita errado ou clica em um link velho, o servidor devolve o código 404 Not Found e uma página feia e genérica. Você pode substituí-la por uma sua: basta um arquivo 404.html na raiz do site publicado. GitHub Pages, Netlify, Vercel e Cloudflare Pages usam esse arquivo automaticamente, sem configuração.
404.html
HTML
<!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>Página não encontrada — Semana Acadêmica</title><linkrel="icon"href="/site-evento/favicon.ico"sizes="32x32"><linkrel="stylesheet"href="/site-evento/css/estilo.css"></head><body><mainclass="erro"><h1>404 — esta página não existe</h1><p>
O endereço que você abriu não corresponde a nenhuma página do site da
Semana Acadêmica. Talvez o link esteja desatualizado.
</p><ul><li><ahref="/site-evento/">Ir para a página inicial</a></li><li><ahref="/site-evento/programacao.html">Ver a programação</a></li><li><ahref="/site-evento/contato.html">Falar com a organização</a></li></ul></main></body></html>
Aqui a regra da §3 se inverte, e por um bom motivo. O 404.html é servido em resposta a qualquer endereço inexistente: /site-evento/programacao/dia-1/, /site-evento/a/b/c/d. A profundidade é imprevisível, então caminho relativo ao documento resolveria para lugares diferentes a cada erro — e a página de erro apareceria sem CSS, o que é uma ironia difícil de justificar. Por isso, e só no 404.html, use caminho relativo à raiz incluindo o nome do repositório: /site-evento/css/estilo.css.
Se um dia o site ganhar domínio próprio (Capítulo 04), ele passa a viver na raiz e esses caminhos viram /css/estilo.css. Deixe um comentário no arquivo lembrando disso.
💡 Dica
Aplicações de página única (o UniEventos do Nível 3, com vue-router) precisam que toda URL devolva o index.html, para o roteador do navegador decidir o que mostrar. Na Netlify, na Vercel e na Cloudflare Pages isso se declara em uma regra de reescrita (§6.3). O GitHub Pages não tem reescrita — o truque conhecido é publicar uma cópia do index.html com o nome 404.html: o servidor devolve a cópia (com status 404, que os buscadores não gostam, mas o navegador ignora) e o roteador assume dali. Funciona; não é elegante.
A Netlify resolve as mesmas necessidades do GitHub Pages com mais recursos: pré-visualização de cada pull request, redirecionamentos, cabeçalhos personalizados, formulários e funções. Para o Café Cerrado estático do Nível 2, ela é a escolha natural.
6.1 Arrastar e soltar: publicando em quarenta segundos¶
O caminho mais rápido do mundo para colocar um site no ar:
Arraste a pasta do site (não um .zip, não os arquivos soltos) para a área indicada.
Pronto. Em segundos você recebe uma URL como https://elegant-pasteur-1a2b3c.netlify.app.
Em Site configuration → Site details → Change site name, troque para cafe-cerrado. A URL vira https://cafe-cerrado.netlify.app.
Use isso para mostrar um trabalho para alguém em cinco minutos, e só. O problema é óbvio: publicar de novo exige arrastar de novo, não há histórico, e o que está no ar não tem relação com o que está no Git. É publicação descartável.
6.2 A partir do Git: o jeito que se usa de verdade¶
No painel, Add new site → Import an existing project → Deploy with GitHub. Autorize o acesso ao repositório cafe-cerrado.
A Netlify pergunta três coisas:
- Branch to deploy: main.
- Build command: vazio, para um site sem construção (HTML/CSS/JS puro). Para um projeto Vite, npm run build.
- Publish directory: . para um site puro (a raiz do repositório); dist para um projeto Vite.
Deploy site. A Netlify clona o repositório, roda o build (se houver) e publica a pasta indicada.
A partir daí, todo git push na main publica automaticamente. E cada pull request aberto ganha um Deploy Preview: uma URL temporária com aquela versão do site, que a Netlify comenta no próprio PR. É a peça que faltava na revisão de código do Capítulo 02 — o revisor deixa de imaginar como ficou e clica para ver.
🧠 Você sabia?
Quem popularizou o nome Jamstack foi a Netlify, por volta de 2015: JavaScript, APIs e Markup. A ideia era dar nome a uma arquitetura em que o HTML é gerado antes (não a cada requisição), servido por CDN, e tudo que é dinâmico chega por chamadas a APIs feitas no navegador. É exatamente a arquitetura dos seus três projetos das trilhas quando ficam prontos: front estático publicado em CDN, conversando com uma API própria.
Configuração feita por cliques no painel não vai para o Git, não é revisada em pull request e some quando o site é recriado. A alternativa é um arquivo na raiz do repositório:
netlify.toml
TOML
# Configuração do site Café Cerrado na Netlify.# Este arquivo tem prioridade sobre o que estiver configurado no painel.[build]# Pasta que vai ao ar. "." = a raiz do repositório (site sem etapa de build).publish="."# Sem comando de build: o site é HTML/CSS/JS puro.command=""[build.environment]NODE_VERSION="22"# Endereço antigo, divulgado no primeiro cardápio impresso: redireciona de vez.[[redirects]]from="/menu.html"to="/cardapio.html"status=301force=true# Cabeçalhos de segurança em todas as respostas.[[headers]]for="/*"[headers.values]X-Content-Type-Options="nosniff"Referrer-Policy="strict-origin-when-cross-origin"# Imagens e fontes mudam pouco: cache longo (uma semana).[[headers]]for="/img/*"[headers.values]Cache-Control="public, max-age=604800"
Três blocos que você vai reusar em quase todo projeto:
[build] define o que é construído e o que é publicado. É o bloco que resolve o erro "Page Not Found" logo depois do primeiro deploy: quase sempre o publish aponta para a pasta errada.
[[redirects]] cria redirecionamentos. Com status = 200 em vez de 301, o redirecionamento vira reescrita: a URL na barra continua a mesma e o conteúdo vem de outro arquivo. É assim que se serve uma SPA:
TOML
[[redirects]]from="/*"to="/index.html"status=200
[[headers]] adiciona cabeçalhos de resposta — segurança, cache, o que você precisar. Cada bloco casa com um padrão de caminho.
A Netlify também aceita os arquivos de texto _redirects e _headers na pasta publicada, com a mesma função e sintaxe mais enxuta (a Cloudflare Pages entende os mesmos dois arquivos). Prefira o netlify.toml: um arquivo só, comentado, e com o build junto.
Para quem prefere o terminal, ou para automatizar depois:
Terminal
# Autentica pelo navegador (uma vez por máquina)
npx--yesnetlify-clilogin
# Liga a pasta atual a um site já existente na sua conta
npx--yesnetlify-clilink
# Publicação de rascunho: gera uma URL temporária para conferir
npx--yesnetlify-clideploy--dir=.
# Publicação definitiva, no endereço oficial do site
npx--yesnetlify-clideploy--prod--dir=.--message"Cardapio com fotos novas"# Estado do site ligado a esta pasta
npx--yesnetlify-clistatus
O deploy sem --prod é um recurso subestimado: ele publica uma URL de rascunho que você abre, confere e só então promove. É a versão em produção do "olha antes de mandar".
As quatro plataformas fazem a mesma coisa básica. As diferenças aparecem no que vem junto:
Plataforma
Ponto forte
Quando escolher
GitHub Pages
Já está onde o código está; zero configuração
Sites simples e projetos de estudo
Netlify
Redirecionamentos, cabeçalhos, previews de PR, formulários
Projeto de equipe, SPA, site com regras
Vercel
Melhor integração com Next.js; build muito rápido
Projeto React/Next
Cloudflare Pages
Rede enorme, banda sem limite declarado
Site com muito acesso ou muita imagem
Vercel. Pelo painel: https://vercel.com/new → importar o repositório do GitHub → ela detecta o framework, sugere o comando de build e a pasta de saída → Deploy. Pelo terminal, na pasta do projeto:
Terminal
npx--yesvercellogin
npx--yesvercel# publica uma URL de pré-visualização
npx--yesvercel--prod# publica no endereço de produção
O cleanUrls: true faz /cardapio servir cardapio.html, sem a extensão na barra de endereço.
Cloudflare Pages. Pelo painel: Workers & Pages → Create → Pages → Connect to Git, escolher o repositório, informar comando de build e diretório de saída. Pelo terminal, com o wrangler:
Ela lê os mesmos arquivos _redirects e _headers da Netlify, o que torna a migração entre as duas quase indolor.
Para os projetos usados nesta trilha, a recomendação é simples: GitHub Pages para o site-evento (é onde o código já está) e Netlify para o cafe-cerrado (você vai precisar de redirecionamento e de cabeçalhos). Publicar o mesmo site em mais de uma plataforma, para comparar de verdade, é um ótimo exercício — e é o desafio ⭐⭐.
8. Cache: por que o site velho insiste em aparecer¶
Você corrigiu o CSS, fez git push, a publicação ficou verde — e o site continua igual. Antes de duvidar da plataforma, saiba que há quatro camadas de cache entre o seu arquivo e o olho de quem visita:
Texto
seu arquivo ─► CDN da plataforma ─► proxy do provedor ─► cache do navegador ─► tela
Cada camada guarda uma cópia pelo tempo que o cabeçalho cache-control autorizar. Leia esse cabeçalho com curl:
Pode guardar por 600 segundos sem perguntar de novo
etag: "a1b2c3"
Impressão digital do conteúdo; muda quando o arquivo muda
age: 240
A cópia que você recebeu está há 240 s no cache intermediário
last-modified
Data da última modificação do arquivo
Com etag, o navegador faz uma requisição condicional na próxima visita: manda If-None-Match e recebe 304 Not Modified (resposta vazia, rápida) se nada mudou. Com max-age ainda válido, ele nem pergunta.
Na sua máquina, para conferir se a correção subiu:
Ação
Como
Recarga forçada
Ctrl+F5 (Windows/Linux) ou Cmd+Shift+R (macOS)
Limpar e recarregar
DevTools aberto → clique direito no botão de recarregar → Empty cache and hard reload
Desligar o cache durante o trabalho
DevTools → Network → marcar Disable cache (vale só com o DevTools aberto)
Testar como um visitante novo
Janela anônima
Para os visitantes, nada disso serve: você não pode pedir a cada pessoa que aperte Ctrl+F5. A solução é mudar o nome do arquivo sempre que o conteúdo muda, para que o navegador seja obrigado a pedir de novo:
HTML
<linkrel="stylesheet"href="css/estilo.css?v=3">
Cada mudança relevante no CSS vira ?v=4, ?v=5. É rústico, mas funciona e é suficiente para um site escrito à mão. Ferramentas de build fazem isso melhor, colocando um resumo do conteúdo no nome (estilo-8f3a91c2.css): mudou um byte, muda o nome, o cache antigo fica órfão e ninguém precisa lembrar de nada.
A partir daí vale a regra de ouro do cache:
HTML: cache curto ou nenhum, porque é ele que aponta para os outros arquivos.
CSS, JS e imagens com nome versionado: cache longo (max-age=31536000, immutable), porque o nome garante que o conteúdo nunca muda.
🔎 Por baixo do capô304 Not Modified é a resposta mais barata do HTTP: o servidor manda só cabeçalhos, sem corpo. O navegador guardou o etag da visita anterior, reenvia em If-None-Match, e o servidor compara. Se bate, 304 e o navegador reaproveita a cópia local. Isso significa que existem duas economias distintas: com max-age válido não há requisição nenhuma (economia de tempo e de banda); com max-age vencido mas etag igual há requisição, mas sem download (economia só de banda). Na aba Network, a coluna Size mostra (disk cache) no primeiro caso e 304 no segundo.
Site no ar não é site pronto. O Lighthouse abre a sua página em condições controladas (rede e processador simulados de celular médio) e devolve nota de 0 a 100 em quatro categorias: Performance, Acessibilidade, Práticas recomendadas e SEO.
Pelo navegador: DevTools → aba Lighthouse → modo Navigation, dispositivo Mobile, marcar as quatro categorias → Analyze page load. Faça isso em uma janela anônima: extensões do navegador injetam scripts e derrubam a nota sem culpa do seu site.
Pelo terminal, o que é útil quando você quer guardar o relatório no repositório:
O --view abre o relatório no navegador ao terminar. Para simular desktop em vez de celular, acrescente --preset=desktop. Uma terceira via, sem instalar nada, é o https://pagespeed.web.dev: ele roda o mesmo Lighthouse nos servidores do Google.
As correções que mais rendem em um site estático típico de estudo, em ordem de retorno:
Problema apontado
Correção
Image elements do not have explicit width and height
Declare width e height no <img> (evita o layout pular)
Serve images in next-gen formats / imagens enormes
Redimensione para o tamanho real de exibição e exporte em WebP
Background and foreground colors do not have a sufficient contrast ratio
Ajuste as cores para contraste mínimo de 4,5:1
Image elements do not have [alt] attributes
Escreva alt descritivo; alt="" só em imagem decorativa
Document does not have a meta description
Adicione <meta name="description" content="…"> em cada página
<html> element does not have a [lang] attribute
<html lang="pt-BR">
Links do not have a discernible name
Link com só um ícone precisa de aria-label
Anote as quatro notas antes de mexer em qualquer coisa. Sem o número inicial, "melhorou" é opinião.
🚀 Passo a passo — o site do evento no GitHub Pages e o Café Cerrado na Netlify¶
O que vai ao ar: o site-evento (Nível 1) em https://<usuario>.github.io/site-evento/, com favicon e página 404, e o cafe-cerrado (Nível 2) em https://cafe-cerrado.netlify.app, publicado a partir do Git com netlify.toml. Troque ana-souza pelo seu usuário do GitHub. Faça na ordem.
Resultado esperado: nothing to commit, working tree clean, visibilidade PUBLIC e a URL do repositório. Se git status listar arquivos pendentes, commite e dê push antes de continuar — o Pages publica o que está no GitHub, não o que está no seu disco.
Cada resultado é um caminho relativo à raiz do domínio. Troque todos por relativos ao documento: /css/estilo.css vira css/estilo.css, /img/logo.png vira img/logo.png. Confira também os nomes dos arquivos de imagem: tudo minúsculo, sem acento e sem espaço (§3.5).
Terminal
ls-1img/
gitadd-A
gitcommit-m"Troca caminhos absolutos por relativos para publicar em subcaminho"
Crie img/favicon.svg, favicon.ico, img/apple-touch-icon.png, img/icone-192.png e img/icone-512.png com os comandos da §4.3 (ou pelo gerador online). Depois:
Cole o bloco de <link rel="icon"> da §4.2 no <head> das cinco páginas.
Crie o site.webmanifest da §4.2 na raiz.
Crie o 404.html da §5, com os caminhos começando em /site-evento/.
Crie o arquivo que desliga o Jekyll:
Terminal
touch.nojekyll
gitadd-A
gitcommit-m"Adiciona favicon, manifesto, pagina 404 e .nojekyll"
gitpush
Resultado esperado: HTTP/2 200 com server: GitHub.com nas duas primeiras e HTTP/2 404 na terceira, com content-type: text/html. Depois abra a URL no navegador em janela anônima, com o DevTools na aba Network: nenhuma linha em vermelho, e a linha do favicon.ico com status 200 apontando para /site-evento/favicon.ico.
Se aparecer linha vermelha, clique nela e leia a Request URL completa. Praticamente sempre é uma destas três causas: caminho com barra inicial que escapou do grep (§3.2), nome de arquivo com maiúscula (§3.5) ou arquivo que não foi commitado. Corrija, git push e recarregue com Ctrl+F5.
Passo 7 — Café Cerrado na Netlify, versão descartável¶
Abra https://app.netlify.com/drop e arraste a pasta cafe-cerrado inteira. Anote a URL sorteada e abra o site. Isso leva menos de um minuto e serve para você ver a diferença para o passo seguinte — não é a forma que você vai usar daqui para a frente.
Passo 8 — Café Cerrado na Netlify, a partir do Git¶
Primeiro, o arquivo de configuração no repositório:
cd~/weblab/cafe-cerrado
gitaddnetlify.toml
gitcommit-m"Configura publicacao e cabecalhos na Netlify"
gitpush
No painel da Netlify: Add new site → Import an existing project → Deploy with GitHub → cafe-cerrado → Deploy. Como o netlify.toml já define publish e command, aceite o que o formulário sugerir. Quando terminar, vá em Site configuration → Site details → Change site name e troque para cafe-cerrado.
Agora prove que o deploy contínuo funciona: mude uma linha visível do site (o título do cardápio, por exemplo), commite, dê push e acompanhe em Deploys no painel. A nova versão sobe sozinha.
A1. A página https://ana-souza.github.io/site-evento/palestrantes.html contém <img src="../img/foto.png">. Escreva a URL exata que o navegador vai pedir. Ela existe? Qual seria a forma correta?
A2. Um repositório se chama portfolio e pertence ao usuário joao-silva. Qual é a URL do site de projeto? E se ele renomear o repositório para joao-silva.github.io, qual passa a ser a URL?
A3. Preveja antes de rodar: em curl -I de um CSS recém-publicado no GitHub Pages, quais destes cabeçalhos você espera encontrar — cache-control, etag, content-type, set-cookie? Rode e confirme. Por que um deles não faz sentido em hospedagem estática?
A4. Explique em duas linhas por que colocar favicon.ico na raiz do repositório não basta em um site de projeto do GitHub Pages, mas basta em um site de usuário.
A5. Complete o netlify.toml para um projeto Vite cujo index.html gerado vai para dist/: qual é o publish, qual é o command e qual bloco você acrescenta para que uma SPA responda em qualquer rota?
A6. Verdadeiro ou falso, justificando: "trocar estilo.css por estilo.css?v=2 no HTML faz o servidor mandar um arquivo diferente". O que exatamente muda?
B1. Quebre e conserte. No site-evento já publicado, crie a branch teste-caminhos, troque todos os href/src do index.html para a forma com barra inicial, commite e publique essa branch no Pages (mude o Branch em Settings → Pages). Documente o estrago e desfaça.
Resultado esperado: um arquivo caminhos.md com uma captura da aba Network mostrando os 404, a Request URL de dois recursos quebrados, a explicação de por que na sua máquina funcionava, e o git revert do commit que quebrou.
Dica
Publique a branch de teste, confira, e devolva o Branch para main antes de sair. git revert <hash> desfaz criando um commit novo — mais honesto que apagar a branch e fingir que nada aconteceu.
B2. Página 404 que se defende. Escreva um 404.html para o seu projeto autoral que funcione em qualquer profundidade de URL e que ofereça ajuda de verdade: os três destinos mais prováveis e um campo de busca que leve para a página inicial com o termo na query string.
Resultado esperado: abrir https://<seu-site>/a/b/c/d mostra a sua página com CSS aplicado, e curl -I na mesma URL devolve 404. A página passa no Lighthouse em Acessibilidade com nota igual ou maior que a da home.
Dica
Os caminhos do 404.html precisam começar com / mais o nome do repositório (§5). O campo de busca pode ser um <form action="/<repo>/index.html" method="get"> com um <input name="q"> e um <label> associado.
B3. O mesmo site, duas plataformas. Publique o site-evento também na Netlify a partir do Git, sem desligar o GitHub Pages, e compare as duas hospedagens.
Resultado esperado: uma tabela em comparacao.md com quatro linhas (URL, server, cache-control do HTML, nota de Performance no Lighthouse) e as duas colunas de plataforma, mais um parágrafo dizendo qual você escolheria para o seu projeto autoral e por quê.
Dica
curl -sI <url> | grep -iE 'server|cache-control' dá as duas primeiras linhas de uma vez. Rode o Lighthouse nas duas URLs na mesma sessão, em janela anônima, para a comparação valer.
C1. Publicação sem cliques. Escreva um script publicar.sh que, rodado dentro de qualquer pasta de site estático não versionada, deixe o site no ar em uma URL do GitHub Pages: cria .nojekyll e .gitignore se não existirem, roda git init e o primeiro commit, cria o repositório remoto com gh repo create, faz push, liga o Pages via gh api, espera o status virar built consultando a API em laço, e imprime a URL final. Rodar o script duas vezes na mesma pasta não pode dar erro nem duplicar nada.
Dica
gh repo view >/dev/null 2>&1 diz se já existe repositório remoto. Para o laço de espera, until [ "$(gh api repos/{owner}/{repo}/pages --jq .status)" = "built" ]; do sleep 5; done, com um contador para desistir depois de vinte tentativas. A URL final sai de gh api repos/{owner}/{repo}/pages --jq .html_url.
É comum alguém publicar o site e receber de volta uma página de texto sem estilo, sem imagem e sem menu — e concluir que "o GitHub Pages não funciona". Ele funciona. O que não funciona é um caminho que começa com barra. Hoje você faz a autópsia desse caso e escreve o laudo que vai economizar horas de quem passar pelo mesmo problema.
Critérios de pronto
Um repositório público autopsia-caminhos com duas páginas idênticas no visual: com-barra.html (todos os href/src relativos à raiz) e sem-barra.html (todos relativos ao documento), compartilhando o mesmo css/estilo.css e a mesma imagem.
O site publicado no GitHub Pages, em subcaminho, com as duas páginas acessíveis.
Um laudo.md com: a URL de cada página; a Request URL completa de três recursos, para cada versão, copiada da aba Network; a explicação de por que as duas se comportam igual no Live Server e diferente no ar.
Uma tabela de três colunas relacionando o que está escrito no HTML, para onde resolve no Live Server e para onde resolve no Pages.
Um parágrafo final respondendo: em que situação um caminho relativo à raiz é a escolha certa?
Pistas
Reveja a §3.2 e a §5 antes de responder à última pergunta — o 404.html é a exceção, e entender por quê vale mais que a tabela inteira.
No DevTools → Network, clique em uma linha e leia Headers → Request URL; é ela que você precisa colar, não o que está escrito no HTML.
Para as duas páginas ficarem realmente idênticas, escreva uma e copie, trocando só os caminhos com um sed 's|="|="/repo/|g' conferido à mão.
Um site de usuário (usuario.github.io) vive na raiz do domínio: lá, as duas versões funcionam. Explique isso no laudo — é a razão de tanta gente jurar que "sempre funcionou".
Para ir além: publique o mesmo repositório na Netlify e mostre que o comportamento muda, porque lá o site fica na raiz do subdomínio.
As quatro plataformas deste capítulo prometem a mesma coisa: seu site no ar, de graça, em uma CDN. Elas entregam a mesma coisa? Descubra medindo, e não lendo página de marketing. O mesmo site, publicado quatro vezes, medido do mesmo jeito, no mesmo dia.
Critérios de pronto
O seu projeto autoral (ou o site-evento) publicado nas quatro: GitHub Pages, Netlify, Vercel e Cloudflare Pages, a partir do mesmo repositório e do mesmo commit.
Um benchmark.md com uma tabela de quatro colunas comparando, para cada plataforma: tempo total de resposta do HTML, cabeçalho cache-control do HTML e cabeçalho cache-control de uma imagem.
Uma segunda tabela com as quatro notas do Lighthouse em cada plataforma, todas medidas em janela anônima, com o mesmo dispositivo simulado.
Três medições de cada tempo, com a mediana registrada — uma medição só não é medição.
Um veredicto de dez linhas: qual você escolheria para um site de portfólio, qual para um site com muitas imagens, qual para um projeto de estudo, e o que te faria mudar de ideia.
Uma seção "o que eu não consegui medir" listando pelo menos duas diferenças relevantes que os seus testes não capturam.
Pistas
curl -o /dev/null -s -w 'total: %{time_total}s conexao: %{time_connect}s\n' <url> mede tempo sem baixar nada para a tela.
Publique nas quatro a partir do mesmo commit; se uma delas construir o site e outra não, a comparação já nasce torta.
Rode as medições em sequência, no mesmo minuto: a sua conexão varia mais ao longo do dia do que as plataformas entre si.
Entre as coisas que você não mede daí: latência a partir de outros países, comportamento sob pico de acesso, e o que acontece quando a plataforma cobra. Cite as fontes que você consultou para essas.
Nota de Lighthouse é fácil de melhorar quando você sabe o que ela mede — e impossível quando você chuta. O trabalho aqui é científico: medir, mudar uma coisa, medir de novo, registrar o efeito. No fim, você vai saber quanto cada técnica vale, em pontos, no seu próprio site.
Critérios de pronto
Ponto de partida documentado: as quatro notas do Lighthouse do seu site publicado, relatório HTML salvo como relatorio-00-inicial.html no repositório.
No mínimo seis otimizações aplicadas, cada uma em um commit separado, com mensagem descrevendo a técnica, e um relatório salvo após cada uma (relatorio-01-…html até relatorio-06-…html).
Cobertura obrigatória de quatro frentes: imagens (dimensão real e formato), fontes (font-display e quantidade de pesos), cabeçalhos de cache (via netlify.toml ou equivalente) e acessibilidade (contraste, alt, lang, rótulos de formulário).
Um otimizacoes.md com uma tabela de quatro colunas: técnica, nota de Performance antes, nota depois, ganho em pontos — ordenada do maior ganho para o menor.
Performance e Acessibilidade em 90 ou mais no modo Mobile, medidos em janela anônima, com os relatórios finais no repositório.
Um parágrafo honesto sobre pelo menos uma otimização que você tentou e que não mudou nada, com a hipótese do porquê.
O peso total da página inicial (soma da coluna Size na aba Network, com o cache desligado) antes e depois, em KB.
Pistas
Comece pelas imagens: em quase todo site de estudo elas são mais de 80% do peso. magick foto.jpg -resize 1200x -quality 82 foto.webp costuma cortar 90%.
width e height no <img> não mudam o peso, mas eliminam o deslocamento de layout — é a métrica CLS, e ela vale pontos.
Fonte do Google Fonts: cada peso extra é um arquivo. Dois pesos bastam. font-display: swap evita texto invisível enquanto a fonte carrega.
Cabeçalho de cache não muda a nota da primeira visita (o Lighthouse simula visitante novo) — esse é um bom candidato à "otimização que não mudou nada", e a explicação é o que vale.
Acessibilidade é a categoria com melhor retorno por minuto: contraste, alt, lang="pt-BR" e <label> costumam somar 20 pontos em meia hora.
Rode cada medição três vezes e use a mediana; a variação entre execuções chega a 5 pontos e pode inventar um ganho que não existe.
Para ir além: isso compõe bem o Marco de qualidade da sua trilha, se os relatórios estiverem no repositório e o histórico mostrar um commit por otimização.
Publique o seu projeto autoral e deixe-o apresentável:
Garanta que o repositório está público e limpo (git status sem pendências) e que não há nenhum caminho com barra inicial fora do 404.html — prove com a saída do grep da §3.4 colada no relatório.
Publique no GitHub Pages (ou na Netlify, se o projeto precisar de redirecionamentos) e anote a URL completa, com barra final.
Adicione favicon (.ico + SVG), apple-touch-icon, site.webmanifest e 404.html, todos funcionando na URL publicada.
Rode o Lighthouse na URL pública e salve o relatório como relatorio-lighthouse.html no repositório.
Escreva no README.md uma seção Site publicado com: a URL clicável, a plataforma escolhida com uma linha de justificativa, e as quatro notas do Lighthouse.
Critério de pronto: a URL abre em janela anônima com todos os recursos carregando (nenhuma linha vermelha no DevTools), o ícone aparece na aba, um endereço inexistente devolve a sua página 404 com CSS aplicado, e o README.md do repositório mostra a URL e as notas.
Guarde no seu repositório: o link do repositório e a URL do site publicado. Nada de .zip.
[ ] O DevTools → Network, com o cache desligado, não mostra nenhuma requisição em vermelho — inclusive a do favicon.
[ ] O ícone aparece na aba do navegador e no atalho de tela inicial do celular.
[ ] https://<seu-usuario>.github.io/site-evento/nao-existe mostra a sua página 404, com CSS, e curl -I confirma o status 404.
[ ] O arquivo .nojekyll está no repositório.
[ ] https://cafe-cerrado.netlify.app abre, e curl -I em uma imagem mostra o cache-control definido no netlify.toml.
[ ] Um git push na main do cafe-cerrado publica a mudança sozinho, sem nenhum clique.
[ ] Você tem, salvos no repositório, os relatórios do Lighthouse dos dois sites, e sabe dizer as quatro notas de cada um.
[ ] Você explica, sem consultar, para onde /css/estilo.css resolve em https://usuario.github.io/site-evento/programacao.html, e por que na sua máquina isso funcionava.
GitHub Docs, "GitHub Pages" — https://docs.github.com/pt/pages — comece por "Criando um site do GitHub Pages" e "Configurando uma fonte de publicação"; é a referência oficial de tudo na §2.
PageSpeed Insights — https://pagespeed.web.dev — o Lighthouse rodando nos servidores do Google, com dados de campo quando o site tem visitantes.
MILETTO, Evandro M.; BERTAGNOLLI, Silvia C. Desenvolvimento de software II. Bookman — capítulo sobre publicação e implantação de aplicações web.
No próximo capítulo, o endereço deixa de ser sorteado pela plataforma: você registra um domínio, entende como um nome vira um IP, aponta evento.seudominio.dev para este mesmo site e coloca o cadeado do HTTPS na barra de endereço.
Explicar, etapa por etapa, como o navegador transforma evento.seudominio.dev em um endereço IP, e o que TTL e "propagação" significam de verdade.
Registrar um domínio (.br no Registro.br ou genérico em outro registrador), ler um WHOIS e separar os três papéis: registrador, provedor de DNS e hospedagem.
Criar registros A, AAAA, CNAME, TXT e MX, escolhendo o tipo certo para cada situação.
Apontar um domínio ou subdomínio para GitHub Pages, Netlify, Vercel ou um VPS, e diagnosticar o resultado com dig, nslookup, host e dnschecker.org.
Descrever o que um certificado TLS prova, como a cadeia de confiança funciona e como a Let's Encrypt emite e renova certificados pelo protocolo ACME.
Forçar HTTPS, ativar HSTS e usar a Cloudflare como DNS e proxy escolhendo o modo SSL correto.
Reconhecer e corrigir os erros clássicos: DNS_PROBE_FINISHED_NXDOMAIN, certificado de outro domínio e conteúdo misto.
[ ] Site do evento acadêmico (Nível 1) publicado no GitHub Pages ou Netlify (Capítulo 03), acessível em https://<usuario>.github.io/<repositorio>/.
[ ] Terminal com dig (Ubuntu/Debian: sudo apt install dnsutils; macOS: já vem instalado; Windows: use o WSL ou o nslookup, que já vem no sistema).
[ ] curl instalado (curl --version).
[ ] Um domínio próprio — opcional, mas recomendado. A §2 mostra opções baratas e gratuitas para quem ainda não tem.
No Capítulo 03 você publicou o site do evento em um endereço que a plataforma escolheu por você, algo como usuario.github.io/site-evento. Funciona, mas ninguém coloca isso num cartão de visita. Hoje o site ganha um nome próprio, com cadeado — e você entende tudo o que acontece entre a barra de endereço e o servidor.
Leia um nome de domínio da direita para a esquerda. O nome completo de evento.seudominio.dev é, tecnicamente, evento.seudominio.dev. — com um ponto final que representa a raiz do DNS:
Parte
Nome técnico
Quem controla
. (ponto final, invisível)
raiz
13 grupos de servidores-raiz, coordenados pela IANA
dev
TLD (domínio de topo)
o operador do TLD (.br é do NIC.br; .dev é do Google)
seudominio
domínio registrado ("apex" ou "raiz do domínio")
você, enquanto pagar a anuidade
evento
subdomínio
você, sem pagar nada a mais
Um domínio registrado dá direito a quantos subdomínios você quiser: evento.seudominio.dev, cafe.seudominio.dev, api.cafe.seudominio.dev. É por isso que um único domínio serve para publicar todos os projetos das trilhas.
Quando você digita evento.seudominio.dev no navegador, ninguém tem a resposta pronta. O nome é resolvido por uma cadeia de perguntas:
Texto
navegador ──► cache do navegador ──► cache do sistema (/etc/hosts, resolvedor local)
│
▼
resolvedor recursivo
(do provedor, 8.8.8.8, 1.1.1.1)
│
1. "quem cuida de .dev?" ▼
◄────────────────────────── servidores-raiz (.)
2. "quem cuida de seudominio.dev?" ▼
◄────────────────────────── servidores do TLD .dev
3. "qual o IP de evento.seudominio.dev?"
◄────────────────────────── servidores autoritativos do seu domínio
(Registro.br, Cloudflare, Netlify DNS)
│
▼
resposta: 185.199.108.153 (TTL 300)
Os papéis:
Resolvedor recursivo (recursive resolver): o "assistente" que faz todas as perguntas em seu nome. Normalmente é o do seu provedor de internet, mas você pode configurar outro (1.1.1.1 da Cloudflare, 8.8.8.8 do Google). Ele guarda as respostas em cache.
Servidores-raiz: sabem apenas quem cuida de cada TLD. Nunca sabem o IP do seu site.
Servidores do TLD: sabem quais são os servidores autoritativos (registros NS) de cada domínio registrado sob eles.
Servidores autoritativos: os únicos que têm a resposta definitiva. É neles que você edita registros. Quem hospeda esses servidores é o seu provedor de DNS — pode ser o registrador (Registro.br oferece de graça), a Cloudflare ou a própria hospedagem.
A analogia clássica: você quer o endereço de uma pessoa. Pergunta à recepção do prédio (resolvedor). A recepção liga para a lista nacional (raiz), que indica a lista do estado (TLD), que indica a prefeitura da cidade (autoritativo), que finalmente sabe a rua e o número. Da próxima vez, a recepção já lembra — por um tempo.
Cada registro DNS carrega um TTL (time to live), em segundos: por quanto tempo um resolvedor pode guardar a resposta antes de perguntar de novo. TTL 3600 significa "confie nesta resposta por uma hora".
É daí que vem a "propagação". DNS não propaga nada — não existe um sinal que sai do seu servidor e se espalha pelo mundo. O que acontece é o oposto: milhares de resolvedores guardam a resposta antiga até o TTL dela expirar. Se o TTL era 86400 (um dia), alguém que acessou o site ontem à noite pode continuar vendo o IP antigo por até um dia inteiro.
Regra prática:
Antes de mudar um registro importante, reduza o TTL para 300 (5 minutos) e espere o TTL antigo expirar.
Faça a mudança.
Confirmado que funcionou, suba o TTL de volta para 3600 ou mais — TTL alto alivia os servidores autoritativos e acelera o acesso.
Existe também o cache negativo: se você consulta um nome que ainda não existe e só depois cria o registro, o resolvedor pode lembrar do "não existe" por alguns minutos. Por isso, crie o registro antes de testar no navegador.
🧠 Você sabia?
Os "13 servidores-raiz" (nomeados de a.root-servers.net a m.root-servers.net) não são 13 máquinas: cada letra é replicada em centenas de locais pelo mundo com uma técnica chamada anycast — o mesmo IP é anunciado de vários pontos e sua consulta vai para o mais próximo. O Brasil hospeda dezenas dessas cópias, várias mantidas pelo NIC.br. E o TLD .dev tem uma peculiaridade: ele inteiro está na lista de HSTS preload dos navegadores, então todo site .dev só abre por HTTPS. Não existe http://alguma-coisa.dev.
🔬 Investigue
Rode dig +trace weblab.aprendabit.com e conte quantos "saltos" aparecem: raiz, .dev, autoritativo. Em seguida rode dig weblab.aprendabit.com duas vezes seguidas e compare o número na coluna do TTL (a segunda coluna da seção ANSWER SECTION). Ele diminui entre uma consulta e outra? Isso é o cache do resolvedor contando o tempo restante.
O Registro.br (https://registro.br) é o registrador oficial de tudo que termina em .br, operado pelo NIC.br, sem fins lucrativos. Para desenvolvedores, as categorias mais usadas são .com.br, .dev.br, .app.br, .tec.br e .eng.br.
O que você precisa saber:
É preciso um CPF ou CNPJ válido. Pessoa física pode registrar.
O preço fica na faixa de algumas dezenas de reais por ano (confira o valor atual no site — ele é o mesmo para quase todas as categorias). Pagamento por Pix ou boleto.
O Registro.br hospeda o DNS de graça: depois de registrar, você edita registros A, CNAME, TXT e MX direto no painel, sem contratar nada.
O domínio é seu enquanto você renovar. Deixou vencer, ele passa por um período de carência e depois volta a ficar disponível para qualquer pessoa. Ative a renovação automática.
Para TLDs genéricos, qualquer registrador credenciado serve. Três com boa reputação entre desenvolvedores: Cloudflare Registrar (vende a preço de custo, mas exige que o DNS fique na Cloudflare), Porkbun e Namecheap. Um .dev custa por volta de uma dezena de dólares por ano.
Cuidado com dois truques comuns: o preço promocional do primeiro ano (a renovação pode custar o dobro) e a "proteção WHOIS" vendida à parte — na maioria dos registradores modernos ela já vem inclusa.
2.3 Sem dinheiro para um domínio? Opções para estudar¶
Duas alternativas mantidas pela comunidade, boas para laboratório e ruins para entregar a um cliente:
is-a.dev (https://is-a.dev): subdomínios gratuitos seunome.is-a.dev, obtidos abrindo um pull request com um arquivo JSON no repositório do projeto — um ótimo exercício depois do Capítulo 02. Aceita CNAME para GitHub Pages e registros A.
DuckDNS (https://www.duckdns.org): subdomínios seunome.duckdns.org apontando para um IP que você pode atualizar por uma URL — útil para um VPS ou até para a sua máquina em casa.
Se você usar uma delas, troque seudominio.dev por seunome.is-a.dev em todos os exemplos deste capítulo.
Todo domínio tem uma ficha pública, o WHOIS, com registrador, datas de criação e expiração e servidores de nome. Dados de contato hoje costumam vir ocultos (LGPD e GDPR), mas o essencial continua visível:
Terminal
sudoaptinstallwhois# Ubuntu/Debian; no macOS já vem
whoisregistro.br
whoisivanpires.dev
Procure na saída as linhas expires/Registry Expiry Date (quando vence) e nserver/Name Server (quem responde pelo domínio). É a forma mais rápida de descobrir onde o DNS de um domínio está hospedado antes de sair procurando painel.
vende o nome e diz ao TLD quais são os servidores NS
Registro.br, Cloudflare Registrar, Porkbun
Provedor de DNS
hospeda a zona: os registros A, CNAME, TXT
DNS do Registro.br, Cloudflare, Netlify DNS
Hospedagem
guarda e serve os arquivos ou roda o processo
GitHub Pages, Netlify, Render, um VPS
Os três podem ser a mesma empresa ou três diferentes. O erro mais comum de iniciante é editar registros no painel do registrador quando os NS apontam para outro provedor — a edição simplesmente não tem efeito. Antes de mexer em qualquer registro, rode dig +short seudominio.dev NS e confirme quem está respondendo.
Uma zona DNS é o conjunto de registros de um domínio. Cada registro tem nome, tipo, valor e TTL. Estes cinco tipos resolvem 95% do dia a dia:
Tipo
Guarda
Exemplo de valor
Use para
A
endereço IPv4
203.0.113.10
apontar para um VPS ou para os IPs fixos de uma plataforma
AAAA
endereço IPv6
2606:50c0:8000::153
o mesmo que A, em IPv6
CNAME
outro nome (apelido)
usuario.github.io.
apontar um subdomínio para um serviço cujo IP pode mudar
TXT
texto livre
google-site-verification=abc123
provar posse do domínio, SPF de e-mail, desafio ACME
MX
servidor de e-mail + prioridade
10 mail.provedor.com.
receber e-mail no domínio
Dois tipos que você vai ver, mas raramente editar: NS (quais servidores são autoritativos — definido no registrador) e CAA (quais autoridades certificadoras podem emitir certificado para o domínio — uma camada extra de segurança).
Uma zona típica de estudante, no formato de arquivo de zona (é assim que o dig mostra as respostas):
Texto
; zona seudominio.dev
seudominio.dev. 3600 IN A 185.199.108.153
seudominio.dev. 3600 IN A 185.199.109.153
seudominio.dev. 3600 IN A 185.199.110.153
seudominio.dev. 3600 IN A 185.199.111.153
www.seudominio.dev. 3600 IN CNAME usuario.github.io.
evento.seudominio.dev. 300 IN CNAME usuario.github.io.
cafe.seudominio.dev. 300 IN CNAME cafe-cerrado.netlify.app.
api.seudominio.dev. 300 IN A 203.0.113.10
seudominio.dev. 3600 IN MX 10 mail.provedor.com.
seudominio.dev. 3600 IN TXT "v=spf1 include:_spf.provedor.com ~all"
_acme-challenge.seudominio.dev. 60 IN TXT "gfj9Xq_Lr3V0w2cZ4pAyT8mHq"
Repare no ponto final depois de usuario.github.io. — em arquivos de zona ele indica nome absoluto. Nos painéis web, normalmente você digita sem o ponto e o painel cuida disso.
Um CNAME diz "para saber o IP deste nome, consulte aquele outro nome". Quando o GitHub troca os IPs do Pages, usuario.github.io passa a resolver para os novos endereços e o seu CNAME continua válido sem que você faça nada. É por isso que as plataformas pedem CNAME para subdomínios.
⚠️ AtençãoCNAME não pode ficar no apex (seudominio.dev sem nada na frente) nem coexistir com outros registros no mesmo nome — é uma regra do protocolo, porque o apex sempre tem NS e SOA. Para o apex, use registros A/AAAA com os IPs fixos que a plataforma publica, ou um provedor de DNS que ofereça ALIAS/"CNAME flattening" — o Cloudflare (que faz o flattening) e o Netlify DNS oferecem; o DNS do Registro.br, não. Painéis que "aceitam" um CNAME no apex costumam quebrar o e-mail do domínio.
Para um subdomínio (evento.seudominio.dev): um único registro CNAME apontando para <usuario>.github.io — sem o nome do repositório. O GitHub descobre qual repositório servir pelo arquivo CNAME que fica na raiz do site publicado.
Para o apex (seudominio.dev): quatro registros A (e, opcionalmente, quatro AAAA) com os IPs fixos do GitHub Pages:
Texto
A 185.199.108.153
A 185.199.109.153
A 185.199.110.153
A 185.199.111.153
AAAA 2606:50c0:8000::153
AAAA 2606:50c0:8001::153
AAAA 2606:50c0:8002::153
AAAA 2606:50c0:8003::153
Depois do DNS, no repositório: Settings → Pages → Custom domain, digite o nome e salve. O GitHub cria um commit com o arquivo CNAME na raiz do site, verifica o DNS ("DNS check successful"), emite um certificado Let's Encrypt e, minutos depois, libera a caixa Enforce HTTPS. Marque-a.
Duas armadilhas:
Se o site é gerado por GitHub Actions (um projeto Vite, por exemplo), o arquivo CNAME precisa estar na saída do build. No Vite, coloque-o em public/CNAME — tudo em public/ é copiado para dist/.
Com domínio próprio, o site sai de usuario.github.io/site-evento/ e passa a viver na raizevento.seudominio.dev/. Links absolutos como /site-evento/css/estilo.css quebram; links relativos (css/estilo.css) continuam funcionando. Se o projeto é Vite, volte base para '/'.
No painel do site: Domain management → Add a domain. Para subdomínio, a Netlify pede um CNAME para <nome-do-site>.netlify.app. Para o apex, ou você delega o DNS inteiro para a Netlify (ela vira seu provedor de DNS) ou cria um A para o balanceador 75.2.60.5 — o painel mostra o valor atual. O certificado é emitido automaticamente pela Let's Encrypt; em HTTPS, ative Force HTTPS.
No projeto: Settings → Domains → Add. Subdomínio: CNAME para cname.vercel-dns.com. Apex: registro A para 76.76.21.21. A Vercel valida o DNS na própria tela (fica verde quando resolve), emite o certificado sozinha e já redireciona HTTP para HTTPS.
Aqui não existe mágica: um registro A com o IP público do servidor (e um AAAA se ele tiver IPv6). O certificado passa a ser responsabilidade sua — é o que o certbot faz no Capítulo 06.
A vantagem do VPS aparece agora: vários subdomínios podem apontar para o mesmo IP (cafe.seudominio.dev, eventos.seudominio.dev, api.seudominio.dev, todos A → 203.0.113.10) e o nginx decide qual site servir pelo cabeçalho Host da requisição. Um servidor, N projetos.
💡 Dica
Adote a convenção um subdomínio por projeto: evento.seudominio.dev para o site do Nível 1, cafe.seudominio.dev para o Café Cerrado, eventos.seudominio.dev para o UniEventos. Subdomínios são grátis, isolam cookies e certificados por projeto e ficam apresentáveis no portfólio. Enquanto configura, use TTL 300 em tudo; quando estabilizar, suba para 3600.
digevento.seudominio.dev# consulta A (padrão), saída completa
dig+shortevento.seudominio.dev# só a resposta
dig+shortevento.seudominio.devCNAME# um tipo específico
dig+shortseudominio.devNS# quem é autoritativo
dig+shortseudominio.devMX
dig+shortseudominio.devTXT
dig@1.1.1.1+shortevento.seudominio.dev# pergunta a um resolvedor específico
dig+traceevento.seudominio.dev# o caminho completo desde a raiz
dig+noall+answerevento.seudominio.dev# só a seção ANSWER, com TTL
dig-x185.199.108.153# reverso: de IP para nome
Anatomia de uma resposta completa:
Texto
;; QUESTION SECTION:
;evento.seudominio.dev. IN A
;; ANSWER SECTION:
evento.seudominio.dev. 300 IN CNAME usuario.github.io.
usuario.github.io. 3600 IN A 185.199.108.153
usuario.github.io. 3600 IN A 185.199.109.153
usuario.github.io. 3600 IN A 185.199.110.153
usuario.github.io. 3600 IN A 185.199.111.153
;; Query time: 24 msec
;; SERVER: 127.0.0.53#53(127.0.0.53)
Leia: o nome é um CNAME para usuario.github.io, que por sua vez tem quatro A. A segunda coluna é o TTL restante. A linha SERVER diz quem respondeu — 127.0.0.53 é o cache local do Ubuntu, não a internet. Para ignorar caches locais, pergunte direto a um resolvedor público com @1.1.1.1.
A comparação decisiva quando "no meu computador funciona e no do colega não":
Terminal
dig+shortevento.seudominio.dev@1.1.1.1
dig+shortevento.seudominio.dev@8.8.8.8
dig+shortevento.seudominio.dev@ns1.registro.br# direto no autoritativo (troque pelo seu NS)
Se o autoritativo já responde certo e os públicos ainda não, é TTL — espere. Se o autoritativo responde errado, o registro está errado ou você editou a zona no provedor errado (§2.5).
nslookup existe em todo Windows; host é um atalho curto no Linux/macOS:
Terminal
nslookupevento.seudominio.dev
nslookup-type=CNAMEevento.seudominio.dev8.8.8.8
hostevento.seudominio.dev
host-tMXseudominio.dev
host-aseudominio.dev# tudo o que conseguir
Quando o dig @1.1.1.1 já responde certo e o navegador insiste no erro, o problema está nos caches da sua máquina:
Terminal
resolvectlflush-caches# Ubuntu (systemd-resolved)
sudodscacheutil-flushcache&&sudokillall-HUPmDNSResponder# macOS
ipconfig/flushdns# Windows (PowerShell/cmd)
O Chrome tem cache próprio: chrome://net-internals/#dns → Clear host cache.
Para ver o mundo de uma vez, use https://dnschecker.org: digite o nome, escolha o tipo e veja o que resolvedores de dezenas de países estão respondendo. É a ferramenta certa para responder "já propagou?" com dados em vez de achismo.
HTTPS é HTTP dentro de um túnel TLS. O túnel garante três coisas: confidencialidade (ninguém no meio lê), integridade (ninguém no meio altera) e autenticidade (você está falando com o dono do nome, não com um impostor). O certificado serve à terceira.
Um certificado é um documento assinado contendo:
o nome (ou nomes) que ele cobre — no campo Subject Alternative Name, como evento.seudominio.dev; um certificado curinga*.seudominio.dev cobre um nível de subdomínio, e só um;
a chave pública do servidor (a privada fica só no servidor e nunca sai de lá);
quem emitiu (a autoridade certificadora, CA) e o período de validade;
a assinatura da CA sobre tudo isso.
O que o cadeado prova, então: que o servidor com quem você fala tem a chave privada correspondente a um certificado que uma CA confiável emitiu para exatamente este nome. Só isso. Ele não prova que o site é honesto, que a loja entrega, que a API não tem bugs. Um site de golpe pode ter cadeado — e hoje quase todos têm.
O navegador não conhece a Let's Encrypt diretamente. Ele conhece um conjunto pequeno de raízes instaladas no sistema operacional. A confiança é uma corrente:
Texto
ISRG Root X1 (raiz; está no seu sistema; chave guardada off-line)
└── assina ► intermediária da Let's Encrypt (renovada periodicamente)
└── assina ► evento.seudominio.dev (o seu, o "folha")
Ao conectar, o servidor envia o certificado folha e a intermediária (por isso o certbot gera um fullchain.pem). O navegador verifica cada assinatura até chegar em uma raiz que ele já tem. Faltou a intermediária? Alguns navegadores buscam sozinhos e funcionam; curl, Android antigo e clientes Node falham com unable to get local issuer certificate. É um bug clássico de "funciona no Chrome, quebra no app".
A Let's Encrypt é uma CA gratuita e automatizada, mantida pela ISRG. Antes dela, certificado custava dinheiro e envolvia formulários; hoje é um comando. Seus certificados valem 90 dias — de propósito, para forçar automação.
A emissão segue o protocolo ACME: um cliente (o certbot, por exemplo) pede um certificado e a CA responde com um desafio para provar que você controla o nome:
Desafio
Como você prova
Precisa de
HTTP-01
servir um arquivo em http://seudominio/.well-known/acme-challenge/<token>
porta 80 aberta e DNS já apontando
DNS-01
criar um TXT em _acme-challenge.seudominio com o valor pedido
acesso ao DNS (manual ou por API)
TLS-ALPN-01
responder na porta 443 com um certificado temporário especial
porta 443, sem porta 80
HTTP-01 é o padrão e o que o GitHub Pages, a Netlify e o certbot --nginx usam. DNS-01 é o único que emite curingas (*.seudominio.dev), porque não dá para servir um arquivo em "todos os subdomínios".
A Let's Encrypt tem limites: cerca de 50 certificados por domínio registrado por semana e 5 certificados idênticos por semana. Enquanto testa, use --dry-run (ambiente de homologação) para não gastar cota.
O certbot é o cliente ACME recomendado pela própria Let's Encrypt. Em um VPS com nginx, o fluxo inteiro é:
Terminal
sudoaptinstallcertbotpython3-certbot-nginx
sudocertbot--nginx-devento.seudominio.dev
sudocertbotrenew--dry-run# simula a renovação: se passar, a automática vai passar
sudocertbotcertificates# lista o que está instalado e quando vence
O pacote instala um timer do systemd (certbot.timer) que roda duas vezes por dia e renova qualquer certificado com menos de 30 dias de validade. Os arquivos ficam em /etc/letsencrypt/live/<dominio>/fullchain.pem e privkey.pem. Você vai fazer isso de verdade no Capítulo 06; nas plataformas gerenciadas (Pages, Netlify, Vercel, Render) tudo isso acontece por você.
Mesmo com redirecionamento de http:// para https://, a primeira requisição de um visitante ainda pode sair sem criptografia — e é nela que um ataque de downgrade age. O cabeçalho HSTS fecha essa brecha:
Ele diz ao navegador: "por um ano, nem tente HTTP neste domínio (e nos subdomínios)". A partir da segunda visita, o navegador reescreve http:// para https:// antes de qualquer conexão. A diretiva preload permite inscrever o domínio em https://hstspreload.org — uma lista embutida nos navegadores, que cobre até a primeira visita. É o que o TLD .dev inteiro tem.
Ative HSTS só quando todos os subdomínios já responderem em HTTPS: com includeSubDomains, um blog.seudominio.dev sem certificado fica inacessível por um ano nos navegadores que já viram o cabeçalho. E preload é, na prática, irreversível.
🔬 Investigue
Veja o certificado do WebLab pelo terminal e responda: para qual nome ele foi emitido, quem assinou e até quando vale?
Depois repita o openssl com -servername de um nome errado (por exemplo -servername exemplo.com) e observe que o servidor pode devolver outro certificado — é exatamente o que gera o erro ERR_CERT_COMMON_NAME_INVALID da §8.
A Cloudflare oferece, no plano gratuito, um provedor de DNS rápido e um proxy reverso global: quando o registro está com a "nuvem laranja" ligada, o dig devolve IPs da Cloudflare, não os do seu servidor. O visitante fala com a Cloudflare; a Cloudflare fala com a sua origem. Ganhos: cache de estáticos perto do usuário, proteção contra DDoS, ocultação do IP real do VPS, HTTPS "de graça" na borda.
Para usar: crie a conta, adicione o domínio, ela importa os registros existentes e pede que você troque os NS no registrador para os dois nomes que ela indicar (algo.ns.cloudflare.com). A partir daí, todo registro tem duas opções: Proxied (laranja) ou DNS only (cinza).
Aqui mora a decisão mais importante — e o erro mais comum. Em SSL/TLS → Overview:
Modo
Visitante ↔ Cloudflare
Cloudflare ↔ seu servidor
Quando usar
Off
HTTP
HTTP
nunca
Flexible
HTTPS
HTTP, sem criptografia
nunca em produção
Full
HTTPS
HTTPS, aceita certificado autoassinado
servidor com certificado de origem da Cloudflare
Full (strict)
HTTPS
HTTPS, exige certificado válido
o padrão a adotar com Let's Encrypt ou certificado de origem
Ative também Always Use HTTPS (redireciona na borda) e, em Edge Certificates, o HSTS — com a mesma cautela da §6.5.
⚠️ Atenção
O modo Flexible mostra cadeado ao visitante enquanto o tráfego entre a Cloudflare e o seu servidor viaja em texto puro pela internet. É segurança de fachada. Ele também gera o famoso ERR_TOO_MANY_REDIRECTS: seu nginx redireciona HTTP para HTTPS, a Cloudflare chega sempre por HTTP, o nginx redireciona de novo, para sempre. Use Full (strict); se o servidor ainda não tem certificado, gere um com o certbot (Capítulo 06) ou instale o certificado de origem gratuito da Cloudflare.
Detalhes práticos com o proxy ligado:
O seu servidor passa a ver o IP da Cloudflare como cliente. O IP real vem no cabeçalho CF-Connecting-IP (e em X-Forwarded-For). Isso importa para logs e limites de taxa no Capítulo 06.
Para GitHub Pages, deixe o registro em DNS only até o GitHub emitir o certificado e liberar Enforce HTTPS; só então ligue o proxy, em Full (strict).
Com o proxy, um subdomínio de API também passa pela Cloudflare — o cache não interfere em respostas JSON por padrão, mas o timeout de 100 segundos por requisição existe.
DNS_PROBE_FINISHED_NXDOMAIN — "este nome não existe" para o resolvedor que o seu navegador usa. Causas, da mais comum para a mais rara: erro de digitação no registro (evento vs eventos), registro criado no provedor de DNS errado (§2.5), TTL ainda não venceu, cache negativo por ter testado antes de criar. Diagnóstico: dig +short nome @1.1.1.1 e dig +short nome @<seu NS autoritativo>.
NET::ERR_CERT_COMMON_NAME_INVALID — o servidor respondeu com um certificado que não cobre o nome digitado. Acontece quando você aponta cafe.seudominio.dev para um VPS cujo nginx só tem certificado para evento.seudominio.dev (ele entrega o certificado do site padrão), quando o GitHub Pages ainda não emitiu o certificado do domínio novo, ou quando www. foi esquecido no certificado. Diagnóstico: o comando openssl da §6.5 com -servername do nome problemático.
NET::ERR_CERT_AUTHORITY_INVALID — o certificado existe, mas foi assinado por alguém que o navegador não conhece: autoassinado (o "snakeoil" do Ubuntu), certificado de origem da Cloudflare exposto sem o proxy, ou cadeia incompleta.
Mixed content (conteúdo misto) — a página veio por HTTPS, mas pede um recurso por http://. O navegador bloqueia conteúdo ativo (scripts, fetch, iframes, CSS) e apenas avisa em conteúdo passivo (imagens, vídeos). Sintoma típico: site publicado com a API em http:// e o console dizendo Mixed Content: The page at 'https://…' was loaded over HTTPS, but requested an insecure resource 'http://…'. This request has been blocked. Correção: use https:// em tudo; enquanto migra, a meta-tag abaixo faz o navegador tentar HTTPS em todo recurso http:// da página:
ERR_TOO_MANY_REDIRECTS — loop de redirecionamento, quase sempre Cloudflare em modo Flexible com um servidor que força HTTPS (§7.1).
ERR_SSL_PROTOCOL_ERROR ou SSL_ERROR_RX_RECORD_TOO_LONG — a porta 443 está respondendo HTTP puro: um listen 443; sem ssl no nginx, ou o certificado ainda não instalado.
🚀 Passo a passo — evento.seudominio.dev com HTTPS¶
O que vai ao ar: o site do evento acadêmico (Nível 1), já publicado no GitHub Pages no Capítulo 03, agora em um subdomínio seu, com certificado e HTTPS obrigatório. Troque seudominio.dev pelo seu domínio (ou por seunome.is-a.dev) e usuario pelo seu usuário do GitHub.
Resultado esperado: HTTP/2 200 e um cabeçalho server: GitHub.com. Se não, volte ao Capítulo 03.
Passo 2 — descubra quem responde pelo seu domínio¶
Terminal
dig+shortseudominio.devNS
Anote os servidores. É nesse provedor (Registro.br, Cloudflare, o painel do registrador) que você vai criar o registro. Se estiver na Cloudflare, mantenha o registro em DNS only por enquanto.
Resultado esperado: a primeira consulta devolve usuario.github.io.; a segunda mostra a cadeia inteira — usuario.github.io. na primeira linha e, abaixo dele, os quatro IPs 185.199.108.153 a 185.199.111.153. Se voltar vazio, espere alguns minutos e repita — e confira o Passo 2. Não abra o navegador ainda (cache negativo, §1.3).
O site saiu de /site-evento/ e agora vive na raiz. Abra os HTMLs e verifique links e src: css/estilo.css e imagens/logo.png (relativos) funcionam; /site-evento/css/estilo.css (absoluto com o nome do repositório) quebra. Ajuste, commit, push.
Volte em Settings → Pages. Quando a caixa Enforce HTTPS ficar clicável, marque. Se ela aparecer desabilitada com a mensagem "Unavailable for your site because your domain is not properly configured", o certificado ainda não foi emitido — espere e recarregue.
o primeiro curl devolve HTTP/2 200 com server: GitHub.com (ou server: cloudflare, se o proxy estiver ligado);
o segundo devolve HTTP/1.1 301 Moved Permanently com location: https://evento.seudominio.dev/ (em um .dev, o navegador nem chega a fazer essa requisição, mas o curl faz);
o openssl mostra subject=CN = evento.seudominio.dev e um issuer da Let's Encrypt, com notAfter cerca de 90 dias à frente;
no navegador, o cadeado abre e mostra o certificado para o seu nome; a página não tem avisos de conteúdo misto no console;
em https://dnschecker.org, o tipo CNAME de evento.seudominio.dev mostra usuario.github.io em todos os locais.
A1. Para o nome blog.cafe.seudominio.dev, liste da direita para a esquerda: raiz, TLD, domínio registrado e subdomínios. Qual é o apex?
A2. Preveja antes de rodar: dig +short www.github.com devolve uma linha CNAME seguida de A, ou só A? Rode e explique a diferença para dig +short github.com.
A3. Um registro A tem TTL 86400 e você acabou de trocar o IP. No pior caso, quanto tempo um visitante que acessou o site ontem à noite pode continuar vendo o IP antigo? E se o TTL fosse 300? Por que a recomendação é baixar o TTL antes da mudança, e não durante?
A4. Verdadeiro ou falso: "um CNAME pode coexistir com um MX no mesmo nome". Justifique com a regra da §3 e diga o que acontece com o e-mail de quem coloca CNAME no apex.
A5. Complete: para apontar api.seudominio.dev para um VPS de IP 203.0.113.10, o registro é do tipo ____, nome ____, valor ____. E para apontar docs.seudominio.dev para um site na Vercel?
A6. Um site está atrás da Cloudflare em modo Flexible e mostra cadeado. Em qual trecho do caminho o tráfego viaja sem criptografia? Que erro aparece se o nginx da origem redireciona HTTP para HTTPS?
B1. Mapa DNS do WebLab. Usando só o terminal, descubra: os servidores NS de ivanpires.dev; para onde weblab.aprendabit.com aponta (CNAME ou A, e os IPs); quem emitiu o certificado e até quando vale; se o site envia HSTS.
Resultado esperado: uma tabela de quatro linhas (NS · apontamento · certificado · HSTS) com o comando usado em cada uma.
Dica
dig +short … NS, dig +noall +answer …, o openssl s_client da §6.5 e curl -sI … | grep -i strict.
B2. Segundo subdomínio, segunda plataforma. Aponte cafe.seudominio.dev para o Café Cerrado estático publicado na Netlify (ou Vercel) no Capítulo 03 e force HTTPS.
Resultado esperado: curl -I https://cafe.seudominio.dev devolve 200 com server: Netlify (ou server: Vercel), e curl -I http://cafe.seudominio.dev devolve um redirecionamento 301/308 para https://.
Dica
Subdomínio é sempre CNAME — o valor está no painel da plataforma (<site>.netlify.app ou cname.vercel-dns.com). Adicione o domínio no painel depois que o dig já responder.
B3. Medindo a "propagação". Crie um registro TXT em teste.seudominio.dev com o valor weblab-<seu-nome> e TTL 60. Cronometre quanto tempo leva até cada um destes responder com o valor: dig @1.1.1.1, dig @8.8.8.8, dig sem @ (o resolvedor do seu provedor) e o dnschecker.org. Depois altere o valor e meça de novo.
Resultado esperado: quatro tempos anotados para a criação e quatro para a alteração; uma frase explicando por que a alteração pode demorar mais que a criação, mesmo com TTL 60.
Dica
Use watch -n 5 'dig +short teste.seudominio.dev TXT @8.8.8.8' para não ficar repetindo o comando. Na alteração, quem já tinha a resposta em cache só pergunta de novo quando o TTL antigo expirar.
B4. Conteúdo misto plantado. No site do evento (publicado em HTTPS), adicione uma imagem carregada por http:// e um <script src="http://…"> de qualquer arquivo JS público. Abra o site, leia o console e anote qual dos dois foi bloqueado e qual só gerou aviso. Corrija com a meta-tag upgrade-insecure-requests e depois da forma definitiva.
Resultado esperado: as duas mensagens do console copiadas, a explicação "ativo × passivo" e o site sem nenhum aviso ao final.
Dica
Scripts são conteúdo ativo: bloqueados. Imagens são passivas: carregam com aviso. A meta-tag muda o comportamento; trocar o http:// por https:// remove o problema.
C1. Cloudflare na frente do GitHub Pages. Migre os NS do seu domínio para a Cloudflare, mantenha evento em DNS only até o certificado do GitHub existir, depois ligue o proxy, escolha Full (strict), ative Always Use HTTPS e HSTS (sem preload). Prove com curl -I que a resposta agora vem com server: cloudflare e com o cabeçalho strict-transport-security, e mostre com dig que os IPs retornados mudaram. Explique, em três linhas, por que o GitHub continua sabendo qual repositório servir mesmo com a Cloudflare no meio.
Dica
A Cloudflare repassa o cabeçalho Host original para a origem; é por ele (e pelo arquivo CNAME) que o GitHub Pages escolhe o site. O dig passa a devolver IPs 104.x ou 172.x da Cloudflare em vez dos 185.199.x do GitHub.
Quantos servidores diferentes precisam ser consultados para que o seu navegador descubra o IP de weblab.aprendabit.com? E de www.uol.com.br? A resposta muda de um nome para outro — e o dig +trace mostra cada parada, com o nome do servidor que respondeu. Descubra o caminho dos dois nomes e explique as diferenças.
Critérios de pronto
Saída do dig +trace dos dois nomes salva em um arquivo de texto, com as linhas de cada "salto" (raiz, TLD, autoritativo) marcadas por você.
Uma lista com o nome de um servidor de cada nível consultado, para cada domínio.
Resposta para: qual dos dois nomes envolve mais níveis de delegação, e por quê (pense em .br versus .com.br).
Resposta para: por que o +trace demora mais que um dig comum, e por que o TTL não aparece diminuindo nele.
Pistas
Leia a seção +trace em man dig: ele ignora o resolvedor recursivo e faz as perguntas ele mesmo, começando pela raiz.
Cada bloco da saída termina com ;; Received … from <IP>#53(<nome>) — esse é o servidor que respondeu naquele nível.
Compare o número de blocos: uol.com.br passa pela raiz, pelo .br, pelo .com.br e pelo autoritativo do domínio; veja se aparece um nível extra em algum dos casos.
Sem cache, o TTL mostrado é sempre o valor original configurado na zona.
Um único domínio é suficiente para o portfólio inteiro do semestre: o apex para a sua página pessoal, um subdomínio para cada projeto, cada um em uma plataforma diferente. Monte essa estrutura de verdade: apex (seudominio.dev) no GitHub Pages com A/AAAA, cafe.seudominio.dev na Netlify ou Vercel com CNAME, e api.seudominio.dev com um registro A para um IP (o do VPS do Capítulo 06, ou um IP qualquer de teste, ou um DuckDNS).
Critérios de pronto
dig +noall +answer dos três nomes salvos, mostrando os tipos corretos de registro em cada um (A/AAAA no apex, CNAME no subdomínio de plataforma, A no subdomínio de VPS).
curl -I https://seudominio.dev e curl -I https://cafe.seudominio.dev devolvendo 200 com HTTPS forçado (o http:// redireciona).
www.seudominio.dev redireciona para o apex (ou o contrário) — sem erro de certificado em nenhum dos dois.
Um README.md no repositório do site pessoal com a tabela de registros e a explicação de por que o apex não pôde usar CNAME.
Pistas
Os IPs do GitHub Pages para o apex estão na §4.1; o www é um CNAME para usuario.github.io e o GitHub cuida do redirecionamento se você cadastrar o apex como domínio principal.
Em cada plataforma, cadastre o domínio só depois de o dig @1.1.1.1 responder certo — assim a verificação passa de primeira.
Se a caixa Enforce HTTPS não liberar, dig o nome de novo: certificado só é emitido quando o DNS resolve para a plataforma.
Para o registro A de api, qualquer IP funciona para o dig; o HTTPS dele fica para o Capítulo 06.
Você tem dez subdomínios de projetos e não quer emitir dez certificados. Um certificado curinga*.seudominio.dev cobre todos — mas a Let's Encrypt só o emite pelo desafio DNS-01, porque não existe como "servir um arquivo em todos os subdomínios". Emita um curinga no seu próprio computador (não precisa de servidor), inspecione-o e explique o que ele cobre e o que não cobre.
Critérios de pronto
Certificado emitido com certbot certonly --manual --preferred-challenges dns para *.seudominio.deveseudominio.dev no mesmo pedido.
Saída de openssl x509 -noout -text -in fullchain.pem | grep -A1 "Subject Alternative Name" mostrando os dois nomes.
Resposta, testada com o dig, para: o registro _acme-challenge ainda precisa existir depois da emissão?
Resposta para: o curinga cobre api.cafe.seudominio.dev? E seudominio.dev sozinho, se você não o tivesse incluído?
Uma explicação de por que a renovação automática não vai funcionar com --manual, e o nome do plugin do certbot que resolveria isso para o seu provedor de DNS.
Pistas
Leia "Wildcard certificates" na documentação da Let's Encrypt e a página do certbot sobre o modo --manual.
O certbot vai imprimir um valor e pedir que você crie um TXT em _acme-challenge.seudominio.dev; confirme com dig +short … TXT @1.1.1.1antes de apertar Enter — se apertar antes, o desafio falha e você gasta cota. Comece com --dry-run.
Um curinga cobre exatamente um nível: *.seudominio.dev inclui cafe.seudominio.dev, mas não api.cafe.seudominio.dev nem o apex.
Existem plugins python3-certbot-dns-cloudflare, dns-route53 e outros que criam o TXT via API; com eles a renovação vira automática.
No seu projeto autoral do Nível 1 (ou do nível que você está cursando), já publicado no Capítulo 03:
Escolha um subdomínio com o nome do projeto (plantas.seudominio.dev, quadras.seudominio.dev). Sem domínio próprio, use seunome.is-a.dev.
Crie o registro DNS correto para a plataforma onde o site está e cadastre o domínio nela.
Force HTTPS e elimine qualquer conteúdo misto.
Salve em um arquivo dns.md na raiz do repositório: a saída de dig +noall +answer <nome>, a saída de curl -I https://<nome> e a de openssl x509 -noout -subject -issuer -dates do certificado.
Critério de pronto: o site abre em https://<subdominio> com cadeado, http:// redireciona, e o console do navegador não mostra avisos de conteúdo misto. O dns.md está commitado.
Guarde no seu repositório: commit + push, com a URL do site na descrição.
No próximo capítulo, o back-end sai da sua máquina: a API do Café Cerrado vai para o Render, com variáveis de ambiente, CORS restrito ao front publicado e um /health para provar que está viva.
Explicar a diferença entre hospedagem estática e servidor de aplicação, e por que um processo Node precisa de outra categoria de hospedagem.
Comparar PaaS, VPS, contêiner gerenciado e funções serverless, escolhendo com critério onde publicar uma API.
Preparar uma API Express 5 para produção: porta vinda do ambiente, escuta em 0.0.0.0, npm ci, campo engines, script start e encerramento gracioso no SIGTERM.
Separar configuração de código com variáveis de ambiente, manter o .env fora do Git, publicar um .env.example e falhar na subida quando faltar um segredo.
Publicar a cafe-cerrado-api no Render a partir do GitHub, com deploy automático a cada push, health check e logs.
Escrever uma rota de health check honesta e ler os logs de um serviço para diagnosticar uma queda.
Configurar CORS liberando exatamente a origem do front publicado — e explicar por que origin: '*' não é a resposta.
Reconhecer os limites do plano gratuito: cold start, disco efêmero, região distante e cota de horas.
[ ] cafe-cerrado-api (Nível 2, Unidade 3) rodando na sua máquina: npm start sobe o Express 5 e curl http://localhost:3000/api/produtos devolve a lista do cardápio.
[ ] Repositório da API no GitHub (Capítulo 02), com o package-lock.json versionado e o node_modules/ no .gitignore.
[ ] Café Cerrado estático publicado no Capítulo 03 (Netlify, Vercel ou GitHub Pages) — é ele que vai consumir a API.
[ ] Node 22 LTS (node -v) e curl (curl --version) na sua máquina.
[ ] Uma conta no Render (https://render.com), criada com o login do GitHub.
[ ] Opcional: o domínio do Capítulo 04, para dar um nome decente à API.
Nos Capítulos 03 e 04 você publicou arquivos: HTML, CSS, JS e imagens que uma CDN entrega sem executar nada, com domínio próprio e HTTPS. Hoje o que vai ao ar é diferente — um processo que precisa estar vivo, ouvindo uma porta, com memória, segredos e um banco (ou um JSON) por baixo. Você vai colocar a cafe-cerrado-api no Render, ligar o front publicado a ela e descobrir, na prática, o que o plano gratuito cobra em troca.
No Capítulo 03 você entregou uma pasta. O GitHub Pages e a Netlify copiaram esses arquivos para servidores espalhados pelo mundo e, quando alguém pede /cardapio.html, devolvem o arquivo. Não há código seu rodando do lado do servidor. Isso explica tudo o que era fácil ali: escala infinita (é só cache), custo zero, nada para reiniciar, nada para monitorar.
O processo precisa de um supervisor. Alguém tem que reiniciá-lo quando ele morre e quando a máquina liga. Numa PaaS isso é automático; num VPS é o pm2 ou o systemd (Capítulo 06).
A porta não é sua escolha. Na sua máquina você fixa 3000. Em produção, quem manda é a plataforma: ela reserva uma porta, escreve o número na variável de ambiente PORT e espera que o seu processo escute exatamente ali. Ignorar isso é o erro nº 1 do primeiro deploy.
O disco é descartável. Em quase toda PaaS, o sistema de arquivos do serviço é efêmero: some a cada novo deploy e a cada reinício. Se a cafe-cerrado-api grava produtos no dados/produtos.json, tudo o que os visitantes criarem desaparece no próximo git push. Isso não é bug, é o modelo — e é exatamente por isso que existe o Capítulo 08, quando o estado sai para um banco de verdade.
Configuração não é código. URL do banco, client ID do Google, origem liberada no CORS: nada disso pode estar dentro do repositório, porque muda de ambiente para ambiente e porque parte é segredo (§5).
🧠 Você sabia?
A ideia de "configuração no ambiente" foi popularizada em 2011 pelo manifesto The Twelve-Factor App, escrito por engenheiros da Heroku — a primeira PaaS a fazer git push heroku main virar um deploy. Doze regras curtas sobre como escrever software que sobe em qualquer lugar; o fator III, Config, é literalmente "guarde configuração no ambiente". Praticamente toda plataforma moderna (Render, Railway, Fly.io, Vercel, Cloud Run) implementa aquele contrato: você entrega um repositório, a plataforma injeta variáveis de ambiente e uma PORT, e espera um processo que escute nela.
Quatro modelos, do mais gerenciado ao mais manual:
Modelo
Você entrega
Você administra
PaaS (Render, Railway, Fly.io)
um repositório Git
só o código e as variáveis
Contêiner gerenciado (Cloud Run, ECS)
uma imagem Docker
a imagem (Capítulo 07)
VPS (Contabo, Hetzner, DigitalOcean)
acesso SSH
tudo: SO, Node, nginx, TLS (Capítulo 06)
Serverless / funções (Vercel, Netlify, Workers)
funções isoladas
nada — mas o modelo muda
Neste capítulo você usa o primeiro modelo, porque ele é o caminho mais curto entre "funciona na minha máquina" e "está na internet". Os outros vêm nos capítulos seguintes, e no fim do semestre você terá visto os quatro para poder escolher com critério.
O Render (https://render.com) conecta ao seu repositório do GitHub, roda um comando de build, roda um comando de start e coloca o processo atrás de um domínio https://<nome>.onrender.com com certificado automático. A cada push na branch escolhida, ele repete tudo.
O que o plano gratuito dá — e o que cobra em troca:
Domínio .onrender.com com HTTPS, e também domínio próprio com certificado emitido pela plataforma.
Deploy automático a cada push, com histórico e botão de rollback para a versão anterior.
Logs em tempo real no painel.
Cold start: um serviço gratuito é desligado depois de cerca de 15 minutos sem receber requisição. A próxima requisição acorda o processo — e espera. Podem ser 30, 50 segundos ou mais. O visitante vê a página girando.
Disco efêmero: sem disco persistente no plano gratuito. Tudo o que o processo grava some no próximo deploy ou reinício.
Cota de horas de execução por mês na conta inteira, e um limite de banda. Confira os números atuais na página de preços antes de publicar cinco serviços.
Região: as regiões ficam nos EUA, Europa e Ásia. De Sinop, cada requisição atravessa o continente. Para um trabalho de faculdade, tudo bem; para um cliente, isso pesa.
O Railway (https://railway.com) tem a melhor experiência de uso do trio: detecta o projeto, sobe banco de dados com dois cliques e mostra tudo num diagrama. Ele não tem plano gratuito permanente — dá um crédito de teste e depois cobra por uso (CPU, memória e banda medidos por segundo). O fluxo pela linha de comando:
O Fly.io (https://fly.io) roda a sua aplicação como microVM perto do usuário, e tem região em São Paulo (gru) — a menor latência para o Brasil entre as três. Ele empacota o projeto em uma imagem (o fly launch gera um Dockerfile se você não tiver um), então casa naturalmente com o Capítulo 07. Também é pago por uso, com valores baixos para um projeto pequeno.
A configuração fica em fly.toml, versionado no repositório. Com auto_stop_machines, a máquina dorme quando não há tráfego — o mesmo cold start do Render, mas medido em milissegundos porque a microVM sobe muito mais rápido que um contêiner clássico.
💡 Dica
Escolha uma plataforma e vá fundo nela neste semestre. Testar as três ao mesmo tempo dá a sensação de produtividade e o conhecimento de nenhuma. O que você aprende aqui — porta pelo ambiente, segredos fora do Git, health check, logs, CORS — vale igual nas três e no VPS.
Todo deploy numa PaaS tem as mesmas cinco etapas. Entender cada uma é o que separa "não sei por que não subiu" de "sei exatamente qual etapa falhou":
Clone. A plataforma baixa a branch configurada do seu repositório. Só o que está commitado existe — se funciona na sua máquina por causa de um arquivo não versionado, vai falhar aqui.
Build. Roda o build command. Para uma API Node, quase sempre npm ci. Para um front com Vite, npm ci && npm run build.
Start. Roda o start command (npm start) e injeta as variáveis de ambiente, incluindo a PORT.
Health check. A plataforma faz requisições ao caminho que você indicou. Enquanto não receber 200, considera que a versão nova não subiu — e mantém a antiga no ar.
Troca de tráfego. Deu certo, o roteador passa a mandar as requisições para a instância nova e derruba a velha. Deu errado, a versão anterior continua servindo e o deploy é marcado como falho.
Um detalhe que economiza horas: a etapa 2 roda com as devDependencies disponíveis, mas a etapa 3 roda só o que está no repositório mais o que o build instalou. Se você importa um pacote que só está em devDependencies, o build passa e o start quebra com Cannot find module.
🔎 Por baixo do capô
Como a plataforma sabe que o seu processo subiu? Ela não lê o seu console.log. Ela abre uma conexão TCP na porta que reservou. Se o processo escutar em 127.0.0.1, ele aceita conexões apenas de dentro do próprio contêiner — e a plataforma, que está fora, recebe "conexão recusada". O sintoma no painel é uma mensagem de port scan timeout: "no open ports detected". A correção é escutar em 0.0.0.0, o endereço curinga que significa "todas as interfaces de rede desta máquina" (§4.2).
Nunca escreva 3000 no listen. Leia da variável PORT e use 3000 só como valor de reserva para a sua máquina:
src/config.js
JavaScript
// src/config.js — lê e valida tudo o que vem do ambiente.// Se faltar algo obrigatório, o processo morre AQUI, com mensagem clara,// em vez de quebrar três dias depois numa requisição qualquer.constambiente=process.env.NODE_ENV??'development';constproducao=ambiente==='production';functiontexto(nome,padrao){constvalor=process.env[nome]??padrao;if(valor===undefined||valor===''){thrownewError(`Configuração ausente: defina ${nome} no ambiente (ou no .env).`);}returnvalor;}functionnumero(nome,padrao){constvalor=Number(texto(nome,padrao));if(!Number.isInteger(valor)||valor<=0){thrownewError(`Configuração inválida: ${nome} precisa ser um inteiro positivo.`);}returnvalor;}functionlista(nome,padrao){returntexto(nome,padrao).split(',').map((item)=>item.trim()).filter((item)=>item.length>0);}exportconstconfig={ambiente,producao,porta:numero('PORT','3000'),host:texto('HOST','0.0.0.0'),origensPermitidas:lista('CORS_ORIGENS','http://localhost:5500,http://127.0.0.1:5500'),// Em produção não há padrão: sem client ID, o processo nem sobe.googleClientId:texto('GOOGLE_CLIENT_ID',producao?undefined:'client-id-de-desenvolvimento'),};
// src/server.js — o único arquivo que sobe o servidor.// Separar 'app' de 'server' permite importar o app nos testes sem abrir porta.import{app}from'./app.js';import{config}from'./config.js';constservidor=app.listen(config.porta,config.host,()=>{console.log(`[cafe-cerrado-api] no ar em ${config.host}:${config.porta} (${config.ambiente})`);});functionencerrar(sinal){console.log(`[cafe-cerrado-api] recebi ${sinal}: parando de aceitar conexões novas.`);servidor.close(()=>{console.log('[cafe-cerrado-api] tudo fechado. Até logo.');process.exit(0);});// Se alguma conexão travar, saia à força depois de 10 s em vez de ficar pendurado.setTimeout(()=>{console.error('[cafe-cerrado-api] demorou demais para encerrar; saindo à força.');process.exit(1);},10_000).unref();}process.on('SIGTERM',()=>encerrar('SIGTERM'));process.on('SIGINT',()=>encerrar('SIGINT'));
Por que tratar SIGTERM? Porque é assim que toda plataforma pede que o processo saia: manda SIGTERM, espera alguns segundos e, se ele insistir em viver, manda SIGKILL. Tratando o sinal, você termina as requisições em andamento antes de sair. Sem tratar, quem estava recebendo uma resposta leva um erro de conexão a cada deploy.
"type": "module" — habilita import/export sem extensão .mjs. Se o seu projeto ainda usa require, mantenha o type fora e nada muda.
"engines" — declara a versão do Node. O Render, o Railway e o Fly.io respeitam esse campo. Sem ele, você pode receber uma versão diferente da sua e descobrir a diferença do jeito ruim.
"scripts.start" — o comando que a plataforma executa. Nunca coloque nodemon ou --watch aqui: em produção, o processo tem que ser um só.
"dependencies" × "devDependencies" — o que a aplicação precisa em produção fica em dependencies. nodemon, eslint e o que roda só na sua máquina ficam em devDependencies.
Os dois instalam dependências, mas fazem coisas diferentes:
Comando
Usa o package-lock.json
Quando usar
npm install
atualiza o lock se achar necessário
na sua máquina, ao adicionar pacote
npm ci
exige o lock e instala exatamente ele
build, CI e produção
O npm ci apaga o node_modules/ e reinstala do zero, na versão exata registrada no lock. É reproduzível e mais rápido. Ele falha se o package.json e o package-lock.json estiverem inconsistentes — o que é bom: significa que alguém commitou um sem o outro.
⚠️ Atenção
O package-lock.jsonprecisa estar no repositório. É comum ver .gitignore de tutorial antigo com package-lock.json dentro. Sem ele, npm ci falha com The npm ci command can only install with an existing package-lock.json e você perde meia hora achando que o problema é a plataforma.
Em produção a sua API nunca fala direto com o navegador: existe um balanceador ou um nginx na frente. Do ponto de vista do Express, todas as requisições vêm do mesmo IP interno e chegam por HTTP simples. O IP real e o protocolo original vêm nos cabeçalhos X-Forwarded-For e X-Forwarded-Proto. Uma linha resolve:
JavaScript
app.set('trust proxy',1);
Com isso, req.ip passa a devolver o IP do visitante e req.protocol devolve https. Isso importa para logs, para limitar requisições por IP e para qualquer redirecionamento que dependa do protocolo.
Nada que muda entre a sua máquina e o servidor pode estar em código versionado. Client ID do Google, senha do banco, chave de API, origem liberada no CORS: tudo vem do ambiente. O código lê process.env; quem preenche é a plataforma.
Na sua máquina, um arquivo .env na raiz do projeto guarda os valores. Ele nunca vai para o Git:
.gitignore
Texto
node_modules/
.env
.env.local
*.log
.DS_Store
O Node 22 lê esse arquivo sozinho, sem biblioteca nenhuma:
Terminal
node--env-file=.envsrc/server.js
npmrundev
E, para documentar quais variáveis existem sem vazar valor nenhum, versiona-se um exemplo:
.env.example
Texto
# Copie este arquivo para .env e preencha. O .env NUNCA vai para o Git.
# Porta local. Em produção a plataforma define esta variável sozinha.
PORT=3000
# 0.0.0.0 = todas as interfaces. Obrigatório em contêiner/PaaS.
HOST=0.0.0.0
# development | production
NODE_ENV=development
# Origens autorizadas a chamar a API pelo navegador, separadas por vírgula.
CORS_ORIGENS=http://localhost:5500,http://127.0.0.1:5500
# Client ID do Google Identity Services (Nível 2, Unidade 3).
GOOGLE_CLIENT_ID=coloque-aqui-o-client-id-do-console-do-google
O .env.example é a documentação executável do seu projeto: quem clonar o repositório copia, preenche e roda.
As mesmas variáveis vão em Environment → Environment Variables, uma a uma. Duas particularidades:
A plataforma injeta PORT sozinha — não crie essa variável manualmente.
Para arquivos inteiros (a chave JSON de uma conta de serviço do Firebase, por exemplo) existe Secret Files: você cola o conteúdo e ele aparece como arquivo no disco do serviço, sem passar pelo Git.
Mudar uma variável dispara um novo deploy. É o comportamento certo: o processo só lê o ambiente quando sobe.
Se um .env foi commitado, remover no commit seguinte não resolve — o valor continua no histórico e qualquer pessoa com o repositório o encontra com git log -p. A ordem é sempre esta:
Revogue e gere outro no serviço de origem (Google Cloud Console, painel do banco). O segredo antigo passa a não valer nada, e é isso que interessa.
Coloque o novo valor no painel da plataforma e no seu .env local.
Só então limpe o histórico, se valer a pena.
Adicione .env ao .gitignore e confira com git ls-files | grep env que nada suspeito está versionado.
⚠️ Atenção
Robôs varrem o GitHub procurando chaves em commits públicos, e o intervalo entre o push e o primeiro uso indevido costuma ser de minutos. Nunca conte com "o repositório é pequeno, ninguém vai ver".
Um health check é uma rota barata que responde "estou de pé". A plataforma a chama a cada poucos segundos; monitores externos (Capítulo 10) também. Ela precisa ser rápida, não precisa de autenticação e não pode devolver dado sensível.
src/app.js
JavaScript
// src/app.js — monta o Express; não abre porta (isso é do server.js).importexpressfrom'express';importcorsfrom'cors';importpathfrom'node:path';import{fileURLToPath}from'node:url';import{config}from'./config.js';importrotasProdutosfrom'./rotas/produtos.js';constaqui=path.dirname(fileURLToPath(import.meta.url));exportconstapp=express();// Atrás do balanceador da plataforma: req.ip e req.protocol passam a ser os reais.app.set('trust proxy',1);app.use(express.json());app.use(cors({origin:config.origensPermitidas}));app.use(express.static(path.join(aqui,'..','public')));// Health check. Dois caminhos: o nosso e o nome que as ferramentas esperam.app.get(['/api/saude','/health'],(requisicao,resposta)=>{resposta.json({status:'ok',ambiente:config.ambiente,versao:process.env.npm_package_version??'desconhecida',segundosNoAr:Math.round(process.uptime()),});});app.use('/api/produtos',rotasProdutos);// 404 em JSON: quem chama uma API espera JSON, não uma página de erro em HTML.app.use((requisicao,resposta)=>{resposta.status(404).json({erro:'Rota não encontrada',caminho:requisicao.originalUrl});});// Tratador de erros do Express 5: quatro parâmetros, sempre por último.// Em produção, a mensagem interna vai só para o log — nunca para o cliente.app.use((erro,requisicao,resposta,proximo)=>{console.error(`[erro] ${requisicao.method}${requisicao.originalUrl} — ${erro.message}`);resposta.status(erro.status??500).json({erro:config.producao?'Erro interno do servidor':erro.message,});});
Repare em três decisões:
O health check aceita /api/saudee/health. Passar um array de caminhos é a forma correta no Express 5, que não aceita mais alternância por expressão regular na rota.
O tratador de erros tem quatro parâmetros. É a assinatura que faz o Express reconhecê-lo como tratador de erros; com três, ele vira um middleware comum e nunca é chamado. No Express 5 você não precisa mais de .catch(next) em handlers async: erros de promessa rejeitada chegam aqui sozinhos.
A mensagem real do erro só aparece no log. Devolver erro.message em produção entrega ao atacante nomes de tabelas, caminhos de arquivo e versões.
Uma rota de saúde que consulta o banco a cada 5 segundos multiplica a carga do banco por nada. Duas variantes bem estabelecidas:
Rota
Responde
Quem usa
liveness (/api/saude)
"o processo está vivo" — sem tocar em dependências
a plataforma, o supervisor
readiness
"consigo atender" — testa banco e serviços
um orquestrador, antes de mandar tráfego
No Café Cerrado, uma rota de liveness basta. E jamais devolva variáveis de ambiente, caminhos absolutos ou a string de conexão do banco: essa rota é pública.
Numa PaaS não existe arquivo de log para você abrir. O que o processo escreve na saída padrão é o log — a plataforma captura, carimba a data e mostra no painel. Ou seja: console.log e console.error são a sua ferramenta.
Três regras que evitam sofrimento:
Uma linha por evento, com um prefixo que identifique de onde veio ([cafe-cerrado-api], [erro]). Painel de logs é lido com busca textual.
Nunca logue segredo.console.log(config) parece inofensivo até imprimir o client ID e a senha do banco em texto puro num painel compartilhado.
Logue o suficiente para reconstruir uma falha: método, caminho, status e tempo. Um middleware de 6 linhas resolve; no Capítulo 10 isso vira log estruturado com o pino.
src/registro.js
JavaScript
// src/registro.js — middleware de log de acesso, em uma linha por requisição.exportfunctionregistrarAcesso(requisicao,resposta,proximo){constinicio=process.hrtime.bigint();resposta.on('finish',()=>{constms=Number(process.hrtime.bigint()-inicio)/1_000_000;console.log(`[acesso] ${requisicao.method}${requisicao.originalUrl}${resposta.statusCode}${ms.toFixed(1)}ms`,);});proximo();}
Use app.use(registrarAcesso); logo depois do trust proxy. O evento finish do objeto de resposta dispara quando a resposta terminou de ser enviada, então o tempo medido é o tempo real da requisição.
🔬 Investigue
Suba a API localmente com npm run dev e, em outro terminal, rode os três comandos abaixo. Compare o corpo, o status e o tempo de cada um; depois olhe o terminal do servidor e confira que cada requisição gerou exatamente uma linha [acesso].
Agora derrube o servidor com Ctrl+C e observe as mensagens de encerramento da §4.2. Quantos milissegundos o processo levou entre receber o sinal e sair?
Enquanto front e back rodavam na mesma máquina, tudo era localhost e nada reclamava. Publicados, eles ficam em origens diferentes: https://cafe-cerrado.netlify.app e https://cafe-cerrado-api.onrender.com. Uma origem é a trinca protocolo + host + porta — mudou qualquer um dos três, é outra origem.
Por padrão, o navegador bloqueia a leitura da resposta de uma requisição para outra origem feita por JavaScript. Repare no verbo: a requisição costuma chegar ao servidor e ser executada; o que o navegador impede é o seu código ler o resultado. A permissão vem de um cabeçalho enviado pelo servidor:
Sem esse cabeçalho, o console mostra a mensagem que você vai ver muito nesta semana:
Texto
Access to fetch at 'https://cafe-cerrado-api.onrender.com/api/produtos'
from origin 'https://cafe-cerrado.netlify.app' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Requisições "simples" (GET, ou POST com formulário) saem direto. Mas um POST com Content-Type: application/json, ou qualquer requisição com cabeçalho Authorization, dispara antes uma verificação prévia (preflight): o navegador manda um OPTIONS perguntando se aquele método e aqueles cabeçalhos são permitidos.
Se a resposta ao OPTIONS não trouxer as permissões, a requisição real nem sai. Sintoma clássico: o GET funciona, o POST falha, e não há nenhum registro do POST no log do servidor.
O pacote cors cuida do OPTIONS e dos cabeçalhos. O que muda em relação ao tutorial genérico é a origem: ela vem da configuração, não fica escrita no código.
Sem barra no final, com o protocolo, exatamente como aparece na barra de endereço. https://cafe-cerrado.netlify.app/ (com barra) não casa.
⚠️ Atençãoorigin: '*' libera qualquer site do mundo a chamar a sua API pelo navegador do visitante. Para uma API pública e somente leitura, pode ser aceitável. Para qualquer coisa que aceite POST, PUT ou DELETE, não é. E existe uma incompatibilidade dura: * é incompatível com credenciais — com credentials: true, o navegador exige uma origem específica e recusa o curinga, com a mensagem The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
Isto derruba muita gente: CORS protege o usuário, não o seu servidor. Ele é uma regra que o navegador aplica. Um curl, um script Node ou o Postman ignoram CORS completamente — sua API responde normalmente a eles. Portanto:
CORS não substitui autenticação. Rota que precisa de login continua precisando verificar o token (Nível 2, Unidade 3).
Se um fetch falha no navegador mas o curl funciona, o problema é CORS. Se falha nos dois, o problema é outro.
projeto com banco junto ou que precise de baixa latência
Para este capítulo: Render. Ele é o que exige menos ferramentas instaladas, tem plano gratuito de verdade e ensina o contrato (build, start, PORT, variáveis, health check) que vale em qualquer lugar.
O que vai ao ar: a API do Café Cerrado, em https://cafe-cerrado-api.onrender.com, com deploy automático a cada push, health check configurado e o site estático publicado no Capítulo 03 consumindo essa URL.
node-v# precisa ser 22.x
npmci# falha aqui? o package-lock.json está inconsistente
npmstart# sobe em 0.0.0.0:3000
curl-shttp://localhost:3000/api/saude
Resultado esperado do curl: {"status":"ok","ambiente":"development","versao":"1.0.0","segundosNoAr":3}.
Confira também o que está versionado:
Terminal
gitls-files|grep-E"package-lock|\.env"
Deve aparecer package-lock.json e .env.example — e não deve aparecer .env.
Se ainda não fez: src/config.js, src/server.js com SIGTERM, app.set('trust proxy', 1), o health check, o CORS por variável, o engines e o script start no package.json, o .env.example e o .gitignore.
Terminal
gitadd.
gitcommit-m"prepara a API para produção: PORT, config, health check e CORS"
gitpush
A aba Logs mostra, em ordem: o clone, o npm ci, o npm start e — se tudo deu certo — a sua própria linha:
Texto
==> Cloning from https://github.com/seu-usuario/cafe-cerrado-api
==> Running build command 'npm ci'...
added 78 packages in 4s
==> Running 'npm start'
[cafe-cerrado-api] no ar em 0.0.0.0:10000 (production)
==> Your service is live 🎉
Repare na porta: 10000, não 3000. Foi o Render quem escolheu, e o seu código obedeceu porque lê process.env.PORT. Se em vez disso aparecer ==> No open ports detected, volte à §4.2.
Settings → Health & Alerts → Health Check Path: /api/saude. Salve. A partir de agora, um deploy só entra no ar depois que essa rota responder 200 — e um deploy quebrado deixa a versão anterior servindo em vez de derrubar o site.
O GET de saúde responde 200 com o JSON. Se for a primeira requisição depois de um tempo parado, ela vai demorar — é o cold start da §2.1. O POST responde 401 se a sua API exige autenticação; isso é sucesso, não erro: significa que a rota existe e a regra funciona.
No repositório do Café Cerrado estático, centralize a URL em um só arquivo:
js/api.js
JavaScript
// js/api.js — único lugar do front que sabe onde a API mora.constlocal=['localhost','127.0.0.1'].includes(window.location.hostname);exportconstAPI_URL=local?'http://localhost:3000':'https://cafe-cerrado-api.onrender.com';exportasyncfunctionbuscarProdutos(){constresposta=awaitfetch(`${API_URL}/api/produtos`);if(!resposta.ok){thrownewError(`A API respondeu ${resposta.status}${resposta.statusText}`);}returnresposta.json();}exportasyncfunctionacordarApi(){// Plano gratuito dorme depois de ~15 min. Chamar o health check no carregamento// da página faz o servidor acordar enquanto o usuário ainda lê o cabeçalho.try{awaitfetch(`${API_URL}/api/saude`,{cache:'no-store'});}catch{console.warn('[api] não consegui acordar a API; ela pode estar iniciando.');}}
Chame acordarApi() no início do script da página e mostre um estado de carregamento honesto enquanto buscarProdutos() não volta. Faça commit e push; a Netlify republica sozinha.
Abra o site publicado, vá ao DevTools → Console. Se aparecer blocked by CORS policy, copie a origem exata que a mensagem cita, coloque-a em CORS_ORIGENS no Render (separando por vírgula, sem barra final) e salve — o serviço reinicia com o valor novo.
Faça uma mudança qualquer visível (o texto de uma mensagem de erro, por exemplo), commit e push. Em Events, o Render mostra o deploy começando sozinho. Se algo quebrar, o mesmo painel tem o botão de voltar para a versão anterior — e é por isso que commits pequenos valem tanto.
A1. Explique em duas frases por que o GitHub Pages não consegue hospedar a cafe-cerrado-api, mesmo o repositório tendo index.html dentro de public/.
A2. Preveja a saída. O código tem app.listen(3000) e a plataforma definiu PORT=10000. O processo sobe? A plataforma consegue falar com ele? Qual mensagem aparece no painel? E se o código tivesse app.listen(process.env.PORT, '127.0.0.1')?
A3. Classifique cada item em dependencies ou devDependencies e justifique: express, nodemon, cors, eslint, google-auth-library, vitest.
A4. Um colega diz: "coloquei origin: '*' e resolvi meu problema de CORS". Cite duas situações em que essa solução falha e uma em que ela é aceitável.
A5. Complete: para que a plataforma consiga abrir uma conexão com o seu processo, ele precisa escutar no host ______ e na porta lida de ______. Se ele escutar em 127.0.0.1, o erro no painel é ______.
A6. A API grava um produto novo em dados/produtos.json e responde 201. Você faz um git push de outro assunto qualquer. O produto continua lá? Explique com a palavra "efêmero" e diga qual capítulo resolve isso.
B1. Faça a API recusar-se a subir quando faltar uma variável obrigatória. Remova GOOGLE_CLIENT_ID do painel do Render (ou do seu .env com NODE_ENV=production) e observe o comportamento; depois melhore a mensagem de erro para que ela liste todas as variáveis faltantes de uma vez, não só a primeira.
Resultado esperado: com duas variáveis ausentes, o processo morre na subida com uma única mensagem do tipo Configuração ausente: GOOGLE_CLIENT_ID, CORS_ORIGENS, e o deploy é marcado como falho sem derrubar a versão que estava no ar.
Dica
Em vez de lançar a exceção dentro de texto(), acumule os nomes que faltaram em um array e lance uma única vez no fim do módulo. Lembre-se de que import executa o módulo inteiro antes de qualquer coisa — é por isso que o erro aparece antes de o servidor abrir a porta.
B2. Meça o cold start. Deixe o serviço parado por 20 minutos, depois cronometre a primeira requisição e mais cinco seguidas.
Resultado esperado: uma tabela com seis tempos, a primeira linha muito maior que as outras, e uma frase explicando a diferença. Comando sugerido: curl -s -o /dev/null -w "%{time_total}\n" https://sua-api.onrender.com/api/saude.
Dica
Use for i in 1 2 3 4 5; do curl -s -o /dev/null -w "%{time_total}\n" URL; done para as cinco seguidas. A diferença entre a primeira e a segunda é o tempo de subir o contêiner, instalar nada (a imagem já está pronta) e rodar npm start.
B3. Prove que CORS é regra de navegador. Faça a mesma requisição de três formas: pelo fetch no console de um site de origem não autorizada (abra qualquer site e use o console), por curl e pelo fetch do site autorizado.
Resultado esperado: a primeira falha com a mensagem de CORS, a segunda devolve o JSON normalmente, a terceira funciona. Um parágrafo explica por que a segunda funciona e o que isso significa para a segurança da sua API.
Dica
No console de um site qualquer, await (await fetch('https://sua-api.onrender.com/api/produtos')).json(). Olhe também a aba Rede: a requisição saiu e voltou com 200; foi o navegador que impediu o JavaScript de ler.
B4. Adicione o middleware registrarAcesso da §6.3 e faça três requisições ruins de propósito: uma rota inexistente, um POST com JSON inválido e um GET numa rota que lança exceção. Leia as três linhas no log do Render.
Resultado esperado: as três linhas [acesso] copiadas do painel, com os status 404, 400 e 500, e a linha [erro] correspondente à terceira. Uma frase explica por que o cliente recebeu "Erro interno do servidor" e não a mensagem real.
Dica
Para forçar o 400: curl -X POST URL/api/produtos -H "Content-Type: application/json" -d '{isso não é json}'. O express.json() rejeita e o erro cai no seu tratador. Para forçar o 500, crie temporariamente uma rota que faça throw new Error('erro de teste').
C1. Publique a mesma API em duas plataformas (Render e Fly.io, ou Render e Railway), a partir do mesmo repositório e do mesmo commit, e compare-as com dados. Meça, de Sinop: latência mediana de 20 requisições ao health check em cada uma, tempo do cold start (se houver), tempo total do deploy e o que cada painel mostra de log. Termine com uma recomendação de uma linha para o Café Cerrado, justificada pelos números.
Dica
O Fly.io precisa de um Dockerfile — o fly launch gera um para projetos Node, e o Capítulo 07 explica cada linha dele. Para a latência, for i in $(seq 20); do curl -s -o /dev/null -w "%{time_total}\n" URL; done | sort -n e pegue o valor do meio. A região gru deve fazer diferença visível; se não fizer, verifique onde a máquina realmente foi criada com fly status.
O /api/saude do Passo a passo responde ok mesmo que o arquivo de dados tenha sumido, que o disco esteja cheio ou que a API não consiga mais ler o cardápio. Ele diz apenas "o processo está vivo" — o que é útil, mas é pouco quando alguém pergunta "o site está funcionando?". Crie uma segunda rota, de prontidão, que verifique de fato as dependências da API e responda com o status HTTP correto.
Critérios de pronto
GET /api/pronto verifica se o arquivo de dados existe e é legível e responde 200 com {"status":"pronto"} quando tudo está bem.
Quando a dependência falha, a rota responde 503 (não 200, não 500) com uma lista das verificações que falharam — sem revelar caminhos absolutos do servidor.
/api/saude continua sem tocar em nada, respondendo em menos de 5 ms.
Um teste manual documentado no README.md: como quebrar a dependência de propósito e o que se espera ver.
O health check do Render continua apontando para /api/saude, e você explica em duas linhas por que não deve apontar para /api/pronto.
Pistas
fs.promises.access(caminho, fs.constants.R_OK) resolve ou rejeita — é a verificação mais barata de "existe e consigo ler".
O código 503 Service Unavailable significa "estou de pé, mas não consigo atender agora". É o status que monitores entendem como indisponibilidade temporária.
Para quebrar de propósito sem apagar nada: renomeie o arquivo, ou mude a permissão com chmod 000.
Se o readiness fosse o health check da plataforma, uma falha momentânea do banco derrubaria o serviço inteiro e reiniciaria o processo em looping — pense no que isso causaria durante um pico de acesso.
Todo time profissional trabalha com três ambientes: local (a sua máquina), homologação (onde se testa sem medo) e produção (onde os usuários estão). Hoje a sua API tem local e produção, e cada push na main vai direto para os visitantes. Monte o do meio: um serviço de homologação ligado à branch desenvolvimento e o de produção ligado à main, com variáveis, dados e URLs diferentes — e o front escolhendo a URL certa sem você editar código na hora do deploy.
Critérios de pronto
Dois serviços no Render a partir do mesmo repositório, em branches diferentes, com nomes distinguíveis (cafe-cerrado-api e cafe-cerrado-api-homolog).
CORS_ORIGENS de cada um libera apenas o front correspondente (produção não aceita a origem de homologação e vice-versa) — provado com dois curl -H "Origin: …".
GET /api/saude de cada serviço devolve ambiente diferente, e a resposta permite identificar em qual você está sem olhar a URL.
Um pull request da desenvolvimento para a main sobe para produção só depois de mesclado; um push direto na desenvolvimentonão afeta produção. Demonstre com o histórico da aba Events.
README.md com uma tabela de três colunas (ambiente · branch · URL) e o procedimento de promoção em até cinco passos.
Pistas
Ao criar o segundo serviço, mude apenas Branch e Name; todo o resto é igual. As variáveis são independentes por serviço.
Para o front escolher a URL, você já tem js/api.js: acrescente uma terceira possibilidade baseada em window.location.hostname, ou publique dois sites (um por branch) na Netlify, que sabe fazer deploy por branch.
Dois serviços gratuitos consomem a mesma cota mensal de horas da conta. Suspenda o de homologação quando não estiver usando.
Se o pull request mesclado não disparar deploy, confira em Settings se o Auto-Deploy está ligado e se a branch está certa.
Faça o teste que ninguém faz antes de entregar: crie três produtos pela API publicada, force um novo deploy e recarregue o cardápio. Eles sumiram. O dados/produtos.json voltou ao estado do repositório, porque o disco do serviço é efêmero — e isso vale para uploads, sessões em arquivo e qualquer coisa que o seu código grave. Documente a perda com evidências e implemente uma solução que sobreviva a três reinícios seguidos, sem ainda usar um banco gerenciado (isso é o Capítulo 08).
Critérios de pronto
Um registro do problema no README.md: as três requisições de criação, a saída do GET antes e depois do deploy, e o horário de cada uma.
Uma solução implementada e funcionando após três deploys consecutivos: pode ser um armazenamento externo por HTTP (um Gist, um bucket, uma planilha via API) ou um disco persistente pago — a escolha é sua, mas precisa estar justificada.
A API continua respondendo em menos de 1 s no caminho feliz: nada de ler o armazenamento externo a cada requisição sem cache em memória.
Tratamento de falha: se o armazenamento externo estiver fora do ar, a API responde 503 na escrita e continua servindo leitura do cache — não devolve 500 genérico nem trava.
Uma seção de 10 linhas no README.md comparando a sua solução com "usar um banco gerenciado" em três eixos: complexidade, custo e risco de perda de dados.
Pistas
Comece medindo: curl de criação, git commit --allow-empty -m "forca deploy" e git push para disparar um deploy sem mudar código, e curl de leitura depois.
O padrão é sempre o mesmo: cache em memória + persistência externa. Leia uma vez na subida, mantenha o array em memória, e grave fora a cada alteração.
Escrever a cada requisição num serviço externo é lento e frágil. Pesquise "write-behind" e considere agrupar as gravações em um intervalo — e o que acontece com as alterações pendentes quando chega o SIGTERM (§4.2).
Se optar por disco persistente, veja que ele fixa o serviço em uma única instância; entenda por que isso impede escalar horizontalmente antes de defender a escolha.
Na API do seu projeto autoral (ou na cafe-cerrado-api, se o seu projeto ainda não tem back-end):
Aplique as cinco preparações da §4: PORT do ambiente, 0.0.0.0, engines, script start e tratamento de SIGTERM.
Crie src/config.js lendo todas as configurações do ambiente, com mensagem de erro clara quando faltar alguma, e um .env.example completo e comentado. Confirme que o .envnão está versionado.
Publique no Render (ou Railway/Fly.io) com health check configurado e deploy automático a partir da main.
Ligue o front publicado à URL da API, com CORS_ORIGENS liberando exatamente aquela origem.
Acrescente ao README.md uma seção "Como publicar" de no máximo 15 linhas: variáveis necessárias, build command, start command, health check path e como ver os logs.
Critério de pronto: um colega consegue, lendo só o seu README.md, dizer quais variáveis precisa cadastrar; curl https://sua-api/api/saude responde 200 com JSON; o site publicado carrega dados da API sem erro no console; e git ls-files não lista nenhum .env.
Guarde no seu repositório: commit + push, com a URL pública da API e a do site na descrição do repositório.
[ ] curl -i https://sua-api/api/saude devolve 200 com {"status":"ok"} e o ambiente correto.
[ ] Nos logs do primeiro deploy aparece a porta escolhida pela plataforma (não 3000), lida de process.env.PORT.
[ ] O site publicado no Capítulo 03 lista os produtos vindos da API, sem nenhum erro no console.
[ ] curl -H "Origin: <origem-do-front>" traz o cabeçalho access-control-allow-origin; com uma origem qualquer, não traz.
[ ] Nenhum segredo está no repositório: git ls-files não mostra .env, e o .env.example documenta todas as variáveis.
[ ] Um push na branch configurada dispara um deploy sozinho, visível na aba Events.
[ ] O Health Check Path aponta para a rota de saúde e um deploy quebrado não derruba a versão anterior.
[ ] Você sabe dizer, sem consultar, quanto tempo leva o cold start da sua API e o que acontece com um arquivo gravado pelo processo depois de um novo deploy.
[ ] O README.md tem a seção "Como publicar" e o package.json tem engines e start.
Render — "Health checks" e "Free instance types": https://render.com/docs/health-checks — o que a plataforma faz com a rota de saúde e os limites do plano gratuito.
The Twelve-Factor App (em português): https://12factor.net/pt_br/ — leia os fatores III (Config), VI (Processos) e IX (Descartabilidade); são exatamente as §§4 e 5 deste capítulo.
No próximo capítulo você sai da PaaS e aluga um servidor inteiro: um VPS com Ubuntu, acesso por SSH com chave, firewall, Node e MySQL instalados na mão, nginx fazendo proxy reverso, pm2 mantendo o processo vivo e o certbot emitindo o certificado — inclusive no estudo de caso de um laboratório real, em ivanpires.dev/dsw/gN/.
Justificar quando um VPS compensa e quando uma PaaS resolve melhor, comparando custo, latência, controle e trabalho de manutenção.
Acessar um servidor Ubuntu por SSH com par de chaves, criar o usuário deploy, desligar o login por senha e ligar o firewall ufw sem se trancar do lado de fora.
Instalar e configurar Node 22, MySQL 8 e nginx em um Ubuntu 24.04, criando banco e usuário com privilégios mínimos.
Escrever um server do nginx que sirva arquivos estáticos e outro que funcione como proxy reverso para 127.0.0.1:3000, entendendo cada proxy_set_header.
Manter um processo Node vivo com pm2 (pm2 start, pm2 save, pm2 startup) e escrever a unidade systemd equivalente.
Emitir e renovar certificados com sudo certbot --nginx, conferindo o timer de renovação automática.
Publicar arquivos no servidor com rsync e explicar o que a barra final e o --delete fazem.
Aplicar esse fluxo a um laboratório real usado como estudo de caso (§11), em https://ivanpires.dev/dsw/gN/: publicar o front em ~/frontend, atualizar o back em ~/backend, reiniciar o serviço e ler os logs — o mesmo passo a passo que você repete no seu próprio VPS.
Diagnosticar 502 Bad Gateway, 403 Forbidden, Permission denied (publickey) e falha de emissão de certificado usando os logs certos.
[ ] Capítulos 02 e 04 concluídos: repositórios no GitHub e um domínio (ou subdomínio gratuito) sob seu controle, com acesso ao painel de DNS.
[ ] Terminal com ssh, rsync e dig (Windows: use o WSL — todos os comandos deste capítulo assumem um shell Linux/macOS).
[ ] Um VPS próprio — a §2 lista opções a partir de poucos reais por mês. Se você cursa a disciplina em que este material nasceu, vale também o acesso ao laboratório (gN@ivanpires.dev) fornecido pelo professor, descrito na §11.
[ ] unieventos-api (Nível 3) e unieventos-web funcionando na sua máquina, com npm run build gerando dist/. Não está no Nível 3? Use a cafe-cerrado-api e o Café Cerrado estático — os passos são idênticos.
[ ] Paciência para errar: você vai se trancar fora do servidor pelo menos uma vez. A §4 mostra como não perder o acesso de vez.
No Capítulo 05 a cafe-cerrado-api subiu numa PaaS: você entregou um repositório e a plataforma cuidou de porta, processo, HTTPS e reinício. O preço foi o cold start, o disco efêmero e um servidor do outro lado do continente. Hoje você troca de lado: aluga uma máquina Linux vazia e monta tudo com as próprias mãos — usuário, firewall, Node, MySQL, nginx, supervisor e certificado. É mais trabalho e é o que revela o que a PaaS fazia por você.
Por que um VPS; criar a máquina; SSH com chave; usuário deploy, ufw e atualizações (§1 a §4)
2
50 min
Node 22, MySQL 8, nginx (estático + proxy reverso), pm2 e systemd (§5 a §8)
3
60 min
certbot, rsync, o laboratório ivanpires.dev/dsw/gN/ e o Passo a passo completo (§9 a §11)
1. Por que um servidor próprio — e quando não vale a pena¶
Um VPS (virtual private server) é uma fatia de um servidor físico com o seu próprio sistema operacional, IP público e acesso de administrador. Você recebe uma máquina Ubuntu vazia e um endereço IP. Tudo o mais é com você.
Controle total. Qualquer versão de qualquer coisa, qualquer porta, qualquer serviço. Nada de "a plataforma não suporta".
Vários projetos no mesmo lugar. Um VPS de 10 reais por mês hospeda os três projetos do semestre, cada um em um subdomínio, com um banco compartilhado — algo que na PaaS seria um serviço (e uma cota) por projeto.
Nada dorme. Sem cold start: o processo fica de pé o tempo todo.
Região. Existem provedores com data center em São Paulo. A diferença de latência para Sinop é visível.
Custo previsível. Preço fixo por mês, sem surpresa por consumo.
Aprendizado. Você entende o que a PaaS escondia. Isso vale para o resto da carreira — e é o que a maioria das vagas de back-end espera que você saiba.
Segurança é sua. Um IP público recebe tentativas de invasão automatizadas em minutos (§4.4). Atualizar o sistema, fechar portas e proteger o SSH passa a ser tarefa sua.
Backup é seu. Apagou o banco? Não há botão de restaurar. O Capítulo 08 trata disso a sério.
Disponibilidade é sua. O processo morreu às 3h da manhã? Ninguém reinicia por você — a não ser que você tenha configurado o supervisor (§8).
Certificado é seu. Renovação vencida = site fora do ar com aviso vermelho. O certbot automatiza, mas você precisa conferir que ele está rodando (§9).
protótipo, trabalho pequeno, sem tempo de manutenção
VPS (este capítulo)
vários projetos, precisa de baixa latência ou de controle
Contêiner (Capítulo 07)
quer o VPS, mas com ambiente reproduzível
Não existe resposta certa universal. Existe a resposta certa para um contexto — e agora você conhece os dois lados para argumentar.
🧠 Você sabia?
O nginx nasceu para resolver um problema com nome próprio: o problema C10K — como atender dez mil conexões simultâneas em uma máquina. Os servidores da época criavam um processo (ou uma thread) por conexão, e a memória acabava muito antes das dez mil. Igor Sysoev escreveu o nginx em 2004 com arquitetura orientada a eventos: poucos processos, cada um cuidando de milhares de conexões em um laço não bloqueante. É exatamente a mesma ideia por trás do event loop do Node.js — dois projetos diferentes, a mesma resposta para a mesma pergunta.
melhor relação recurso/preço; data centers na Europa e nos EUA
DigitalOcean, Vultr, Linode
documentação excelente; região em São Paulo em alguns planos
Oracle Cloud (Always Free)
camada gratuita permanente com máquinas ARM; cadastro exige cartão
Provedores brasileiros
latência baixa e suporte em português; preços em real
Para os projetos deste semestre, o menor plano serve: 1 vCPU, 1 a 2 GB de RAM, 20 GB de disco. O gargalo aparecerá muito antes na sua consulta SQL do que no hardware.
Escolha Ubuntu Server 24.04 LTS. LTS significa suporte longo — atualizações de segurança por anos, sem precisar migrar de versão no meio do semestre. É a distribuição com mais tutoriais e a que todo provedor oferece.
Se o painel do provedor oferecer adicionar uma chave SSH na criação, faça isso (§3.1): a máquina já nasce sem senha de acesso, o que elimina a janela de risco entre criar e proteger.
Anote: o IP público (algo como 203.0.113.10) e a senha de root, se o provedor mandar uma por e-mail.
Uma chave SSH é um par de arquivos: a privada (~/.ssh/id_ed25519), que nunca sai da sua máquina, e a pública (~/.ssh/id_ed25519.pub), que você copia para todo servidor onde quiser entrar. O servidor lança um desafio que só quem tem a privada consegue responder. Nenhuma senha viaja pela rede.
Terminal
ls~/.ssh# já existe id_ed25519? então pule
ssh-keygen-ted25519-C"seu-email@exemplo.com"
cat~/.ssh/id_ed25519.pub
O ssh-keygen pergunta onde salvar (aceite o padrão com Enter) e uma frase secreta. Use uma: ela criptografa a chave privada em disco, de modo que um notebook roubado não vira acesso ao servidor. Para não digitá-la a cada comando, o ssh-agent guarda a chave destravada durante a sessão:
Na primeira vez aparece a pergunta sobre a impressão digital do servidor. Responder yes grava a chave pública do servidor em ~/.ssh/known_hosts; a partir daí, o seu cliente avisa se ela mudar (o que pode significar um servidor recriado — ou um ataque).
Para instalar a sua chave pública no servidor sem editar arquivo nenhum:
Terminal
ssh-copy-idroot@203.0.113.10
Ele acrescenta a linha ao ~/.ssh/authorized_keys do servidor, com as permissões certas (700 na pasta .ssh, 600 no arquivo) — permissão errada é a causa nº 1 de "copiei a chave e continua pedindo senha".
Digitar ssh deploy@203.0.113.10 vinte vezes por dia é desperdício. O arquivo de configuração do cliente resolve:
~/.ssh/config
Texto
Host meuvps
HostName 203.0.113.10
User deploy
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 60
Host dsw
HostName ivanpires.dev
User g3
IdentityFile ~/.ssh/id_ed25519
A partir daí, ssh meuvps e ssh dsw bastam — e o rsync e o scp também entendem o apelido. ServerAliveInterval 60 manda um pacote de vida por minuto e evita que a sessão caia sozinha quando você fica lendo documentação.
💡 Dica
Comandos úteis no dia a dia: ssh meuvps 'uptime' roda um comando e volta sem abrir sessão interativa; scp arquivo.sql meuvps:~/ copia um arquivo; ssh -v meuvps mostra o passo a passo da autenticação e é a melhor ferramenta para entender um Permission denied (publickey).
apt update atualiza a lista de pacotes disponíveis; apt upgrade instala as versões novas. São coisas diferentes, e confundi-las gera aquele "atualizei e nada mudou".
Trabalhar como root é como programar com o dedo no botão de formatar: um comando errado apaga tudo, sem confirmação. Crie um usuário comum com poder de sudo:
Com a chave funcionando, desligue a senha e o login direto de root. No Ubuntu 24.04, o /etc/ssh/sshd_config começa com Include /etc/ssh/sshd_config.d/*.conf, então o jeito limpo é criar um arquivo próprio:
Terminal
sudonano/etc/ssh/sshd_config.d/00-weblab.conf
/etc/ssh/sshd_config.d/00-weblab.conf
Texto
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
Terminal
sudosshd-t# testa a sintaxe; sem saída = tudo certo
sudosystemctlrestartssh
⚠️ Atenção
O número no nome do arquivo importa: no SSH, para cada opção vale o primeiro valor encontrado, e os arquivos são lidos em ordem alfabética. Imagens de nuvem costumam trazer um /etc/ssh/sshd_config.d/50-cloud-init.conf com PasswordAuthentication yes — por isso o nosso arquivo se chama 00-weblab.conf, e não 99-. Confira o resultado real com sudo sshd -T | grep -i passwordauthentication: tem que responder passwordauthentication no. E nunca feche a sessão atual antes de abrir uma nova em outro terminal para validar; enquanto a antiga estiver viva, você ainda consegue desfazer.
Se você rodar ufw enableantes de liberar o OpenSSH, a sua sessão cai e você fica sem acesso — recuperável só pelo console de emergência do painel do provedor. Falta abrir as portas 80 e 443, e para isso existe o perfil Nginx Full; ele só passa a existir depois que o nginx é instalado — rodá-lo agora responde ERROR: Could not find a profile matching 'Nginx Full'. Por isso o sudo ufw allow 'Nginx Full' fica na §7.1, junto da instalação do nginx.
Em um servidor com poucas horas de vida esse número já costuma passar de mil. Não é pessoal: são robôs varrendo faixas inteiras de IP tentando admin, test, ubuntu e senhas óbvias. Com senha desligada, todos falham.
O unattended-upgrades instala sozinho as atualizações de segurança. O fail2ban lê os logs e bloqueia temporariamente o IP que erra a autenticação várias vezes seguidas. O status sshd mostra quantos IPs já foram banidos — costuma ser uma boa surpresa.
🔬 Investigue
Descubra em quantos segundos o seu servidor novo recebe a primeira tentativa de invasão. Logo depois de criar a máquina, rode:
A segunda linha ranqueia os IPs mais insistentes. Escolha um e procure de que país ele é (whois <ip> | grep -i country). Depois responda: com PasswordAuthentication no, o que exatamente acontece com essas tentativas? Elas param de aparecer no log?
O nvm é ótimo na sua máquina — troca de versão em um comando. Mas ele instala o Node dentro de ~/.nvm/versions/node/v22.x/bin/node e ativa a versão através de um trecho no ~/.bashrc, que só roda em shell interativo. O systemd não abre shell interativo. O resultado é o erro clássico status=203/EXEC ao iniciar o serviço: "o arquivo não existe" — porque o caminho node só existe dentro do seu shell.
Se insistir no nvm, use o caminho absoluto no ExecStart e aceite que ele muda a cada atualização. Em servidor, prefira o pacote do sistema.
Instalar pacotes globais (sudo npm install -g pm2) escreve em /usr/lib/node_modules. Funciona, é o caminho mais direto, e é o que vamos usar para o pm2. Só tome cuidado com um detalhe: comandos instalados assim ficam em /usr/bin e são visíveis a todos — mas o sudo de algumas distribuições reseta o PATH e "esconde" binários instalados em outros lugares. Se sudo pm2 disser command not found e pm2 sozinho funcionar, é isso.
O mysql_secure_installation faz quatro perguntas que importam: ativar o validador de senhas (aceite), definir a senha do root (defina uma longa), remover usuários anônimos e o banco test (sim), e desativar login remoto de root (sim).
utf8mb4 é o conjunto de caracteres que guarda todo o Unicode, emoji incluído; o antigo utf8 do MySQL guarda no máximo três bytes por caractere e corrompe emoji. 'unieventos'@'localhost' restringe o usuário a conexões vindas da própria máquina.
A saída do ss precisa mostrar 127.0.0.1:3306, nunca 0.0.0.0:3306. Um MySQL exposto na internet com senha fraca é comprometido em horas — existem varreduras dedicadas a isso. Se algum dia você precisar acessar o banco do servidor pela sua máquina, não abra a porta: use um túnel SSH, que passa por dentro da conexão que você já tem.
O primeiro comando abre a porta 3307 na sua máquina e a liga, por dentro do SSH, à porta 3306 do servidor. Nenhum byte de MySQL trafega desprotegido, e o firewall continua fechado.
server{listen80;listen[::]:80;server_nameeventos.seudominio.dev;root/var/www/unieventos-web;indexindex.html;# SPA: qualquer rota desconhecida devolve o index.html,# e o vue-router decide o que mostrar no lado do navegador.location/{try_files$uri$uri//index.html;}# Arquivos com hash no nome (gerados pelo Vite) podem ser cacheados para sempre:# se o conteúdo mudar, o nome do arquivo muda junto.location~*\.(css|js|woff2|png|jpg|jpeg|svg|webp|ico)${expires1y;add_headerCache-Control"public,immutable";access_logoff;}gzipon;gzip_typestext/cssapplication/javascriptapplication/jsonimage/svg+xml;gzip_min_length1024;}
nginx -t testa a configuração sem aplicar. Nunca faça reload sem ele: uma vírgula errada derruba todos os sites da máquina. E prefira reload a restart: o reload troca a configuração sem derrubar as conexões em andamento.
Um proxy reverso é um servidor que recebe a requisição do visitante e a repassa para outro processo, devolvendo a resposta. Quem fala com a internet é o nginx; o Node fica escondido em 127.0.0.1:3000, inalcançável de fora.
Por que não deixar o Node atender a porta 443 direto? Porque o nginx faz melhor cinco coisas que o Node faria pior: termina o TLS, serve arquivos estáticos, comprime, limita taxa e — o principal — permite vários sites na mesma máquina, escolhidos pelo cabeçalho Host.
/etc/nginx/sites-available/api.seudominio.dev
nginx
server{listen80;listen[::]:80;server_nameapi.seudominio.dev;# Corpo máximo aceito (upload de imagem de evento, por exemplo).client_max_body_size5m;location/{proxy_passhttp://127.0.0.1:3000;proxy_http_version1.1;# Sem estas quatro linhas, a API só enxerga o nginx.proxy_set_headerHost$host;proxy_set_headerX-Real-IP$remote_addr;proxy_set_headerX-Forwarded-For$proxy_add_x_forwarded_for;proxy_set_headerX-Forwarded-Proto$scheme;proxy_connect_timeout5s;proxy_read_timeout60s;}}
Os quatro proxy_set_header são o contrato com o app.set('trust proxy', 1) que você escreveu no Capítulo 05:
Cabeçalho
O que carrega
Host
o domínio que o visitante digitou
X-Real-IP
o IP do visitante
X-Forwarded-For
a cadeia de proxies por onde a requisição passou
X-Forwarded-Proto
http ou https — o protocolo original
Sem eles, req.ip na API devolve 127.0.0.1 para todo mundo e req.protocol devolve http mesmo em páginas com cadeado.
🔎 Por baixo do capô
Como o nginx decide qual server usa, se todos escutam na mesma porta 80? Ele lê o cabeçalho Host da requisição HTTP e procura um server_name que case. Se nenhum casar, usa o primeiro bloco declarado (ou o marcado com default_server) — foi por isso que, no Capítulo 04, apontar um domínio novo para um VPS devolvia o certificado de outro site. Teste você mesmo, sem mexer no DNS:
Enquanto existir o arquivo /etc/nginx/sites-enabled/default, qualquer nome desconhecido apontado para o seu IP mostra a página "Welcome to nginx" — inclusive nomes de terceiros. Duas opções: remover o link (sudo rm /etc/nginx/sites-enabled/default) ou substituí-lo por um bloco que simplesmente fecha a conexão:
Rodar node src/server.js numa sessão SSH funciona até você fechar o terminal. Precisa de um supervisor: alguém que suba o processo no boot, reinicie quando ele morrer e guarde os logs.
Esta é a parte que quase todo mundo esquece — e descobre no primeiro reinício, com o site fora do ar:
Terminal
pm2startup
O comando não faz nada sozinho: ele imprime uma linha começando com sudo env PATH=... que você deve copiar e colar. Essa linha cria uma unidade systemd que chama o pm2 no boot. Depois:
Terminal
pm2save
O pm2 save grava a lista atual de processos em ~/.pm2/dump.pm2. É essa lista que o pm2 restaura no boot. Mudou a lista (adicionou ou removeu um app)? Rode pm2 save de novo, ou a mudança se perde no próximo reinício.
Passar tudo por linha de comando não é reproduzível. Descreva o serviço em um arquivo versionado no repositório. Como o package.json da API tem "type": "module" (Capítulo 05 §4.3) e o pm2 lê o arquivo como CommonJS, a extensão precisa ser .cjs:
ecosystem.config.cjs
JavaScript
// ecosystem.config.cjs — descrição do processo para o pm2.// Rode com: pm2 start ecosystem.config.cjsmodule.exports={apps:[{name:'unieventos-api',script:'src/server.js',cwd:'/home/deploy/apps/unieventos-api',// Carrega o .env que está ao lado do código: é ele que traz// DB_USER, DB_PASSWORD, DB_NAME e as demais chaves.node_args:'--env-file=.env',instances:1,exec_mode:'fork',env:{NODE_ENV:'production',// Só o nginx conversa com a API: escutar em loopback é mais seguro// do que em 0.0.0.0, que era o obrigatório na PaaS do Capítulo 05.HOST:'127.0.0.1',PORT:3000,},max_memory_restart:'300M',autorestart:true,time:true,out_file:'/home/deploy/logs/unieventos-api.out.log',error_file:'/home/deploy/logs/unieventos-api.err.log',},],};
Os segredos (senha do banco, chaves) não entram aqui: este arquivo vai para o Git. Eles ficam no .env do servidor, com permissão chmod 600, e quem os carrega é o node_args: '--env-file=.env' acima — sem essa linha o pm2 sobe o processo só com NODE_ENV, HOST e PORT, e a API morre na validação da configuração por falta de DB_USER/DB_PASSWORD/DB_NAME.
O pm2 é conveniente, mas o Ubuntu já tem um supervisor: o systemd, o mesmo que cuida do nginx e do MySQL. É o que o estudo de caso da §11 usa, e vale conhecer.
/etc/systemd/system/unieventos-api.service
Config
[Unit]Description=API do UniEventosAfter=network.target mysql.service[Service]Type=simpleUser=deployGroup=deployWorkingDirectory=/home/deploy/apps/unieventos-apiEnvironmentFile=/home/deploy/apps/unieventos-api/.envExecStart=/usr/bin/node src/server.jsRestart=on-failureRestartSec=5StandardOutput=journalStandardError=journal[Install]WantedBy=multi-user.target
O certbot pede um e-mail (para avisos de expiração), pede aceite dos termos, resolve o desafio HTTP-01 servindo um arquivo em /.well-known/acme-challenge/ pelo próprio nginx, e então edita os seus arquivos de configuração: acrescenta listen 443 ssl;, as diretivas ssl_certificate e ssl_certificate_key, e cria um bloco que redireciona http:// para https:// com 301.
Confira o resultado:
Terminal
sudocertbotcertificates
sudonginx-t
curl-Ihttp://api.seudominio.dev# 301 para https
curl-Ihttps://api.seudominio.dev# 200
Certificados da Let's Encrypt valem 90 dias. O pacote instala um timer do systemd que roda duas vezes por dia e renova o que estiver a menos de 30 dias do vencimento:
O --dry-run faz o ensaio completo contra o ambiente de homologação da Let's Encrypt, sem gastar cota nem trocar o certificado real. Se ele passa, a renovação automática vai passar. Rode-o hoje; assim você não descobre o problema daqui a três meses, com o site fora do ar.
⚠️ Atenção
Depois que o certbot mexeu nos arquivos, edite-os com cuidado: se você apagar o server_name ou trocar o nome do arquivo, a renovação seguinte falha com Could not automatically find a matching server block. E, se um dia precisar refazer tudo, use --dry-run primeiro — a Let's Encrypt limita a poucos certificados idênticos por semana, e estourar o limite deixa você esperando dias.
O rsync copia só o que mudou, comparando tamanho e data de modificação. Para um site estático, é a ferramenta certa: rápido, incremental e por cima do SSH.
copia recursivamente preservando permissões e datas
-v
mostra os arquivos transferidos
-z
comprime durante a transferência
--delete
apaga no destino o que não existe mais na origem
--dry-run
simula e lista o que faria, sem copiar nada
E o detalhe que mais causa confusão: a barra final na origem. dist/ copia o conteúdo de dist para dentro do destino; dist (sem barra) copia a pastadist para dentro do destino, criando /var/www/unieventos-web/dist/. Sempre rode com --dry-run na primeira vez — ainda mais com --delete, que apaga de verdade.
Guarde a linha de publicação em um script do package.json para não errar a digitação nunca mais:
JSON
{"scripts":{"publicar":"npm run build && rsync -avz --delete dist/ meuvps:/var/www/unieventos-web/"}}
11. Estudo de caso: o laboratório da turma em ivanpires.dev/dsw/gN/¶
Esta seção descreve o servidor real da turma da disciplina em que este material nasceu — é o mesmo VPS que hospeda este WebLab, com uma conta por grupo. Se você é aluno dessa turma, é o ambiente que você vai usar, e o acesso é fornecido pelo professor. Se está estudando por conta própria, leia esta seção como um estudo de caso completo de tudo o que as §§1–10 ensinaram, aplicado a um servidor de verdade — e repita o mesmo desenho no seu próprio VPS. Substitua N pelo número do seu grupo em tudo o que segue — os exemplos usam o grupo 3.
O front fica em ~/frontend e é servido pelo nginx em /dsw/gN/. O back roda em ~/backend, escuta em 127.0.0.1:350N e recebe as requisições de /dsw/gN/api/ por proxy reverso. O banco db_gN é acessível só de localhost.
Na primeira vez, quem tem conta nesse laboratório envia a chave pública ao professor (cat ~/.ssh/id_ed25519.pub) — a conta não aceita senha, exatamente como você configurou o seu VPS na §4.3.
11.2 Como o professor montou isso (e você repetirá no seu VPS)¶
O lado do servidor é o que você acabou de estudar. Um trecho do server de ivanpires.dev:
nginx
# Front do grupo 3: arquivos estáticos em /home/g3/frontendlocation/dsw/g3/{alias/home/g3/frontend/;try_files$uri$uri//dsw/g3/index.html;}# API do grupo 3: proxy para a porta 3503, só em loopbacklocation/dsw/g3/api/{proxy_passhttp://127.0.0.1:3503/api/;proxy_http_version1.1;proxy_set_headerHost$host;proxy_set_headerX-Real-IP$remote_addr;proxy_set_headerX-Forwarded-For$proxy_add_x_forwarded_for;proxy_set_headerX-Forwarded-Proto$scheme;}
E a unidade do serviço, no molde da §8.4:
/etc/systemd/system/dsw-g3.service
Config
[Unit]Description=API do grupo 3 (DSW)After=network.target mysql.service[Service]Type=simpleUser=g3Group=g3WorkingDirectory=/home/g3/backendEnvironmentFile=/home/g3/backend/.envExecStart=/usr/bin/node src/server.jsRestart=on-failureRestartSec=5[Install]WantedBy=multi-user.target
Repare no location /dsw/g3/api/ com proxy_pass http://127.0.0.1:3503/api/: os dois terminam em barra. Isso faz o nginx substituir o prefixo /dsw/g3/api/ por /api/ antes de repassar — então a sua API continua respondendo em /api/eventos como na sua máquina, sem saber que existe um prefixo. Tire a barra final do proxy_pass e o caminho inteiro é repassado; a API recebe /dsw/g3/api/eventos e devolve 404.
Nesse laboratório, o sudo systemctl restart dsw-g3 é o único comando privilegiado liberado para a conta do grupo: o professor autorizou exatamente essa linha na configuração do sudo. Qualquer outro sudo responde que a conta não está no arquivo de permissões — e isso é proposital: um grupo não consegue derrubar o serviço de outro nem tocar na configuração do nginx. No seu próprio VPS, você é o root, então é você quem decide (com visudo) se quer se impor a mesma restrição.
O seu site vive em /dsw/g3/, não na raiz. Tudo o que for caminho absoluto quebra:
<link href="/css/estilo.css"> procura https://ivanpires.dev/css/estilo.css — que não existe. Use caminhos relativos ou o prefixo completo.
Em um projeto Vite, ajuste a base antes de gerar o build.
vite.config.js
JavaScript
import{defineConfig}from'vite';importvuefrom'@vitejs/plugin-vue';exportdefaultdefineConfig({plugins:[vue()],// O site é servido em https://ivanpires.dev/dsw/g3/, não na raiz.base:'/dsw/g3/',});
import.meta.env.BASE_URL recebe automaticamente o valor de base do vite.config.js — assim o prefixo fica escrito em um só lugar. E a URL da API no front vira /dsw/g3/api, um caminho relativo à mesma origem: sem CORS, porque front e back compartilham https://ivanpires.dev.
O que vai ao ar: https://eventos.seudominio.dev servindo o unieventos-web e https://api.seudominio.dev servindo a unieventos-api por proxy reverso, com MySQL local, pm2 e certificado válido. Troque seudominio.dev pelo seu domínio (Capítulo 04) e 203.0.113.10 pelo IP do seu VPS. Está no Nível 2? Troque UniEventos por Café Cerrado; os comandos são os mesmos.
Em outro terminal do servidor: curl -s http://127.0.0.1:3000/api/saude deve responder {"status":"ok"} (a mesma rota do Capítulo 05 §6.1, que também atende em /health). Encerre com Ctrl+C.
Ainda no Passo 7, crie o ecosystem.config.cjs da §8.3 na raiz do projeto — na sua máquina, com git add ecosystem.config.cjs, commit e push, e depois git pull no servidor; ou direto no servidor com nano ecosystem.config.cjs, lembrando de levá-lo para o repositório em seguida. Ele não guarda segredo nenhum (é o node_args: '--env-file=.env' que lê o .env do servidor), então deve ficar versionado: é a descrição do processo, e sem ele o Passo 8 não tem o que iniciar.
sudonpminstall-gpm2
cd~/apps/unieventos-api
pm2startecosystem.config.cjs
pm2ls
pm2logsunieventos-api--lines20
pm2startup# copie e cole a linha 'sudo env PATH=...' que ele imprimir
pm2save
A1. Explique, em duas frases, a diferença entre chave pública e chave privada, e diga qual das duas vai para o servidor e em qual arquivo.
A2. Preveja a saída. Você roda, nesta ordem: sudo ufw enable e depois sudo ufw allow OpenSSH. O que acontece com a sua sessão SSH atual? E com a próxima tentativa de conexão? Como você recuperaria o acesso?
A3. O que muda entre rsync -avz dist/ meuvps:/var/www/site/ e rsync -avz dist meuvps:/var/www/site/? Desenhe a árvore de diretórios resultante nos dois casos.
A4. A API responde em http://127.0.0.1:3000/api/saude dentro do servidor, mas https://api.seudominio.dev/api/saude devolve 502 Bad Gateway. Liste três causas possíveis, em ordem do mais provável ao menos provável, e o comando que confirma cada uma.
A5. Complete: no Capítulo 05 a API precisava escutar em ______ porque ______; neste capítulo ela escuta em ______ porque ______.
A6. Um colega diz: "coloquei pm2 start e testei o reboot; não voltou". Quais dois comandos faltaram, e o que cada um deles guarda?
B1. Hospede um segundo site no mesmo VPS: publique o site-evento (Nível 1) em evento.seudominio.dev, com registro DNS, server próprio no nginx, rsync e certificado.
Resultado esperado: os dois sites respondem 200 em HTTPS pelo mesmo IP; curl -H "Host: evento.seudominio.dev" http://203.0.113.10 traz o site do evento e curl -H "Host: eventos.seudominio.dev" http://203.0.113.10 traz o UniEventos.
Dica
Copie o arquivo da §7.2, troque server_name e root, crie a pasta com chown deploy, ln -s, nginx -t, reload. O certbot aceita vários -d em um só comando e reaproveita o certificado existente se você usar --expand.
B2. Provoque e diagnostique um 502 Bad Gateway. Pare a API (pm2 stop unieventos-api), acesse a URL pública, leia o erro no navegador e depois encontre a linha correspondente no log do nginx. Suba a API de novo e confirme que o erro sumiu.
Resultado esperado: a linha do /var/log/nginx/error.log copiada, contendo connect() failed (111: Connection refused) while connecting to upstream, e uma explicação de qual processo recusou a conexão e por quê.
Dica
sudo tail -f /var/log/nginx/error.log em um terminal enquanto você acessa a URL em outro. O nginx registra o upstream que tentou alcançar — compare-o com o proxy_pass do seu arquivo.
B3. Faça o túnel SSH da §6.3 e conecte-se ao MySQL do servidor a partir de um cliente gráfico (DBeaver, MySQL Workbench, ou a extensão do VS Code) rodando na sua máquina, sem abrir a porta 3306 no firewall.
Resultado esperado: o cliente gráfico lista as tabelas do banco unieventos; sudo ufw status continua sem nenhuma regra para 3306; e você consegue explicar por onde os dados trafegaram.
Dica
ssh -L 3307:127.0.0.1:3306 meuvps e, no cliente, host 127.0.0.1, porta 3307. Deixe o terminal do túnel aberto: fechando-o, a conexão do cliente cai. ssh -fNL 3307:127.0.0.1:3306 meuvps roda o túnel em segundo plano.
B4. Compare pm2 e systemd na prática: pare o pm2, escreva a unidade da §8.4 para a mesma API, suba por systemd, mate o processo à força (kill -9 <pid>) e cronometre em quanto tempo ele volta. Repita com o pm2.
Resultado esperado: uma tabela de quatro linhas (supervisor · comando de status · onde ficam os logs · tempo até voltar depois do kill -9) e uma recomendação justificada para o seu projeto.
Dica
Descubra o PID com pm2 ls ou systemctl show -p MainPID unieventos-api. O RestartSec=5 da unidade define a espera do systemd; o pm2 reinicia quase instantaneamente, mas tem proteção contra laço de reinício se o processo morrer rápido demais várias vezes seguidas.
C1. Publique o seu projeto do zero, em 20 minutos, seguindo o roteiro do estudo de caso da §11: build do front com base correta, rsync para ~/frontend, git pull e npm ci --omit=dev no ~/backend, .env apontando para o banco do projeto, sudo systemctl restart do serviço e verificação no endereço público. Se você tem acesso ao laboratório da turma, use os nomes de lá (db_gN, dsw-gN, https://ivanpires.dev/dsw/gN/); no seu próprio VPS, use os nomes que você escolheu. Documente cada comando em um arquivo PUBLICAR.md no repositório, de modo que qualquer pessoa consiga repetir sem perguntar nada.
Dica
Comece pelo base: '/dsw/gN/' do vite.config.js — sem isso, o site abre em branco e o console mostra 404 em todos os .js e .css. A URL da API no front deve ser relativa (/dsw/gN/api), nunca http://localhost:350N. Se o serviço não subir, journalctl -u dsw-gN -n 50 --no-pager mostra a exceção do Node.
O seu VPS tem poucas horas de vida e já é alvo de milhares de tentativas de acesso. Elas vêm de robôs que varrem faixas inteiras de IP procurando senhas fracas — e ficam todas registradas. Faça a auditoria: descubra quantas tentativas houve, de onde vieram, quais usuários foram testados e o que aconteceria se você tivesse deixado a autenticação por senha ligada.
Critérios de pronto
Um auditoria.md no repositório com: total de tentativas de login inválidas nas últimas 24 h, os 10 IPs mais insistentes com país de origem, e os 10 nomes de usuário mais tentados.
O comando usado em cada número, copiado exatamente como você rodou.
A saída de sudo fail2ban-client status sshd mostrando pelo menos um IP banido, com a explicação de qual regra o baniu.
A saída de sudo sshd -T | grep -i -E "passwordauthentication|permitrootlogin" provando que nenhuma dessas tentativas poderia ter sucesso.
Um parágrafo respondendo: por que as tentativas continuam aparecendo no log mesmo com a senha desligada?
Pistas
sudo journalctl -u ssh --since "24 hours ago" é a fonte; grep, awk '{print $NF}', sort | uniq -c | sort -rn | head fazem a contagem.
Para os usuários tentados, procure as linhas Invalid user <nome> from <ip> — o nome é o penúltimo campo antes de from.
whois <ip> | grep -i -E "country|netname" identifica a origem; muitos IPs pertencem a provedores de nuvem, não a "hackers em porões".
O SSH registra a tentativa antes de decidir se o método é aceito; o que muda com a senha desligada é o desfecho, não o registro.
O certbot deixa o seu site funcionando, mas com a configuração TLS padrão. Submeta https://eventos.seudominio.dev ao teste do SSL Labs (https://www.ssllabs.com/ssltest/) e veja a nota. Depois melhore a configuração até chegar a A — e entenda cada mudança, em vez de colar um bloco de configuração pronto da internet.
Critérios de pronto
Captura (ou texto) do relatório do SSL Labs antes e depois, com as notas.
Nota final A ou superior, com os quatro grupos de pontuação do relatório anotados.
Cabeçalhos de segurança presentes na resposta, provados com curl -sI https://eventos.seudominio.dev: Strict-Transport-Security, X-Content-Type-Options e Referrer-Policy.
Um arquivo de configuração comentado, com uma linha explicando cada diretiva que você acrescentou — sem diretiva copiada que você não saiba justificar.
sudo nginx -t passa e sudo certbot renew --dry-run continua passando depois das mudanças.
Pistas
O certbot cria /etc/letsencrypt/options-ssl-nginx.conf com um conjunto razoável de protocolos e cifras; leia-o antes de mudar qualquer coisa.
Os pontos que costumam faltar: TLS 1.0/1.1 ainda habilitados, ausência de HSTS e chave Diffie-Hellman fraca (ssl_dhparam, gerado com openssl dhparam -out /etc/nginx/dhparam.pem 2048).
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; — releia a §6.5 do Capítulo 04 antes de ligar includeSubDomains, e não use preload ainda.
ssl_stapling on; e ssl_session_cache shared:SSL:10m; melhoram desempenho; confirme o efeito no próprio relatório do SSL Labs.
Hoje o seu deploy é uma sequência de comandos digitados à mão que deixa o site fora do ar por alguns segundos — e, se algo falhar no meio, deixa o servidor num estado híbrido, com o front novo e a API antiga. Escreva um script de publicação que seja repetível, verificável e reversível: se qualquer etapa falhar, ele volta o servidor exatamente ao estado anterior.
Critérios de pronto
Um publicar.sh no repositório, com set -euo pipefail, que publica front e back em um comando e imprime o que está fazendo.
Publicação do front por troca de link simbólico: os arquivos vão para /var/www/unieventos-web/releases/<data-hora>/, e só depois de o rsync terminar o link current passa a apontar para a versão nova. O root do nginx aponta para current.
Verificação automática depois de reiniciar a API: o script consulta a rota de saúde por até 30 segundos e, se ela não responder 200, refaz o link para a versão anterior, reinicia a API na versão anterior e sai com código diferente de zero.
Um teste real de rollback: quebre a API de propósito (uma variável de ambiente errada), rode o script e mostre que o site continuou no ar com a versão antiga.
README.md com o número de segundos em que o site ficou indisponível durante uma publicação bem-sucedida, medido por você.
Pistas
ln -sfn /var/www/unieventos-web/releases/<data-hora> /var/www/unieventos-web/current troca o alvo de um link de forma atômica; o -n evita criar um link dentro do diretório apontado.
O nginx segue o link a cada requisição, então não precisa de reload para ver a versão nova — mas confira se root aponta para current e não para o caminho real.
Para a verificação, um laço for com curl -fsS URL/api/saude e sleep 2; a opção -f faz o curl sair com erro em status HTTP de falha, o que combina com o set -e.
pm2 reload (em vez de restart) e um trap no shell para executar o rollback quando o script sair com erro são as duas peças que fecham o desafio.
Guarde as três últimas versões e apague as mais antigas — senão o disco de 20 GB acaba no meio do semestre.
🔥
🔥 Boss — Os três projetos no ar, com HTTPS, em subdomínios¶
deploynginxhttpsdnsprojeto
Um servidor. Um domínio. Três projetos do semestre, cada um em um subdomínio, todos com cadeado — e um README.md que qualquer pessoa consegue seguir para reconstruir tudo do zero em uma máquina nova. É o fechamento da Unidade 2: DNS (Capítulo 04), publicação estática (Capítulo 03), back-end (Capítulo 05) e servidor próprio (este capítulo) funcionando juntos, na sua infraestrutura.
Os três: o site do evento (Nível 1, estático), o Café Cerrado (Nível 2: front estático + cafe-cerrado-api) e o UniEventos (Nível 3: unieventos-web + unieventos-api com MySQL). Cinco endereços no total, dois processos Node em portas diferentes, um único IP.
Critérios de pronto
Cinco subdomínios respondendo 200 em HTTPS, com certificado válido para o nome exato: evento, cafe, api-cafe, eventos e api — e http:// redirecionando com 301 em todos.
sudo ss -tlnp mostra os dois processos Node em 127.0.0.1 (portas 3000 e 3001) e o MySQL em 127.0.0.1; sudo ufw status lista apenas OpenSSH e Nginx Full.
pm2 ls mostra os dois processos online, e um sudo reboot traz os cinco endereços de volta sem nenhuma intervenção.
Cada API só aceita a origem do seu próprio front no CORS: provado com seis curl -H "Origin: …" (dois por API, um permitido e um negado).
sudo certbot certificates lista os certificados cobrindo os cinco nomes, e sudo certbot renew --dry-run passa.
Um INFRAESTRUTURA.md no repositório com: tabela de subdomínio → projeto → pasta ou porta, os arquivos do nginx comentados, os comandos de publicação de cada projeto e o procedimento de recuperação ("o servidor pegou fogo; como refazer tudo em uma máquina nova").
Um teste de carga simples em um dos endereços estáticos, com o resultado anotado — e uma frase dizendo o que quebraria primeiro se o tráfego decuplicasse.
Pistas
Cinco registros A para o mesmo IP (Capítulo 04 §4.4). Confirme todos com dig +short … @1.1.1.1antes de rodar o certbot, e emita tudo num comando só: sudo certbot --nginx -d evento.seudominio.dev -d cafe.seudominio.dev -d api-cafe.seudominio.dev -d eventos.seudominio.dev -d api.seudominio.dev.
Duas APIs na mesma máquina precisam de portas diferentes. Defina PORT no env de cada app do ecosystem.config.cjs e confira com sudo ss -tlnp antes de configurar o nginx.
Um arquivo de server por subdomínio em sites-available deixa o diagnóstico muito mais fácil do que um arquivo gigante. Repita a §7.2 para os três estáticos e a §7.3 para as duas APIs, mudando server_name, root e a porta do proxy_pass.
Para o teste de carga, ab -n 500 -c 20 https://evento.seudominio.dev/ (pacote apache2-utils) ou curl em laço. Olhe htop durante o teste: o que satura primeiro, CPU, memória ou rede?
O procedimento de recuperação fica muito mais curto se você anotar os comandos enquanto executa, em vez de tentar lembrar depois. É exatamente esse arquivo que o Capítulo 07 vai transformar em Dockerfile e o Capítulo 09, em automação.
No seu projeto autoral (ou no projeto do grupo, se você tem acesso ao laboratório da turma):
Publique o front e a API no servidor, seguindo o Passo a passo (ou a §11, se estiver usando gN@ivanpires.dev).
Garanta que a API escuta apenas em 127.0.0.1 e que o nginx é a única porta de entrada. Comprove com sudo ss -tlnp.
Escreva um PUBLICAR.md na raiz do repositório com o procedimento completo de publicação: os comandos exatos, na ordem, com o que se espera ver depois de cada um.
Registre no mesmo arquivo uma seção "Se der errado" com três problemas que você enfrentou e como diagnosticou cada um (o comando que revelou a causa, não só a solução).
Critério de pronto: o endereço público responde em HTTPS, sem erro no console do navegador; sudo ss -tlnp mostra o Node em loopback; e um colega consegue publicar uma alteração seguindo apenas o seu PUBLICAR.md, sem fazer perguntas.
Guarde no seu repositório: commit + push, com a URL pública na descrição.
[ ] ssh meuvps entra sem pedir senha, e ssh -o PubkeyAuthentication=no meuvps é recusado.
[ ] sudo ufw status lista apenas OpenSSH e Nginx Full; sudo sshd -T | grep -i permitrootlogin responde no.
[ ] sudo ss -tlnp mostra o nginx em 0.0.0.0:80 e 0.0.0.0:443, o Node em 127.0.0.1:3000 e o MySQL em 127.0.0.1:3306.
[ ] curl -I https://eventos.seudominio.dev devolve 200, e curl -I http://eventos.seudominio.dev devolve 301 para HTTPS.
[ ] curl -s https://api.seudominio.dev/api/saude responde {"status":"ok"} com ambiente igual a production.
[ ] pm2 ls mostra a API online; depois de sudo reboot, tudo volta sem intervenção.
[ ] sudo certbot certificates lista os certificados com validade futura e sudo certbot renew --dry-run passa sem erro.
[ ] rsync -avz --delete dist/ meuvps:/var/www/... publica uma alteração do front e você a vê no navegador depois de um Ctrl+F5.
[ ] Se você usa o laboratório da turma, https://ivanpires.dev/dsw/gN/ abre o seu projeto, com o front carregando de ~/frontend e a API respondendo em /dsw/gN/api/. Se usa o seu próprio VPS, o mesmo vale no seu domínio.
[ ] Você tem um PUBLICAR.md que permite repetir tudo sem consultar este capítulo.
No próximo capítulo, tudo o que você instalou na mão vira receita: o Docker empacota a unieventos-api, o MySQL e o site em contêineres que rodam idênticos no seu notebook e neste mesmo VPS — e você descobre por que "funciona na minha máquina" deixa de ser desculpa.
Explicar, com suas palavras, o que é um contêiner, em que ele difere de uma máquina virtual e qual problema o Docker resolve no dia a dia de um time.
Distinguir imagem, contêiner, volume e rede, e usar os comandos básicos (run, ps, logs, exec, stop, rm) para inspecionar e controlar contêineres.
Escrever um Dockerfile correto para uma API Node: imagem pequena, cache de camadas, sem devDependencies, sem rodar como root.
Escrever um Dockerfile multi-stage que gera o build de um site Vite e o serve com nginx.
Orquestrar API + MySQL com docker compose, usando volume para persistir dados, healthcheck e depends_on para controlar a ordem de subida, e .env para configuração.
Publicar uma imagem no GitHub Container Registry e rodá-la no VPS do Capítulo 06 atrás do nginx.
[ ] unieventos-api na forma final da trilha do Nível 3: src/app.js + src/server.js, configuração validada em src/config/index.js, npm run migrar aplicando migrations/*.sql e GET /health respondendo { "status": "ok" }.
[ ] unieventos-web (Vue 3 + Vite) gerando dist/ com npm run build.
[ ] VPS do Capítulo 06 acessível por SSH — o mesmo servidor, pelo mesmo alias meuvps (usuário deploy) do ~/.ssh/config —, com nginx fazendo proxy reverso para 127.0.0.1:3000.
[ ] Conta no GitHub (Capítulo 02) — o registro de imagens fica lá.
[ ] 4 GB de disco livres e permissão de administrador na sua máquina para instalar o Docker.
No Capítulo 06 você alugou um VPS e subiu a API "na mão": apt install, npm ci, pm2 start. Funcionou — mas repare em quanta coisa ficou "instalada no servidor": versão do Node, versão do MySQL, pacotes do sistema, o usuário que roda o processo. Se amanhã você precisar de um segundo servidor, ou um colega precisar rodar o mesmo ambiente na máquina dele, tudo isso precisa ser refeito, na mesma ordem, sem esquecer nada. Hoje o mesmo VPS passa a rodar a API e o MySQL como contêineres — idênticos no seu notebook, no do colega e no servidor —, a partir de uma imagem construída na sua máquina e publicada no GitHub. No Capítulo 08 o banco sai do VPS e vai para um serviço gerenciado; no Capítulo 09 o GitHub Actions passa a construir e publicar essa imagem sozinho a cada push.
A frase mais cara da história do software é "na minha máquina funciona". Ela custa caro porque é verdadeira: o código é o mesmo, mas o ambiente não é. Alguns exemplos que você provavelmente já viveu:
O colega tem Node 18, você tem Node 22 — e --env-file não existe no dele.
O MySQL do laboratório está em 8.0, o seu em 8.4 — e o plugin de autenticação padrão mudou.
No VPS o processo roda como root; no seu notebook, como o seu usuário — e a permissão de um arquivo é diferente.
Você instalou um pacote global (npm install -g) meses atrás, esqueceu, e o projeto depende dele sem ninguém saber.
Um contêiner resolve isso empacotando, junto com o seu código, tudo de que ele precisa para rodar: a versão exata do Node, as dependências, os arquivos de configuração, o usuário, até a distribuição Linux mínima por baixo. O pacote (a imagem) é construído uma vez e roda igual em qualquer lugar onde exista um Docker — seu notebook, o do colega, o VPS, o servidor de CI.
Uma máquina virtual emula um computador inteiro, com kernel próprio, e leva minutos para subir. Um contêiner é só um processo comum do Linux, isolado do resto do sistema por dois recursos do kernel: namespaces (o processo enxerga a própria árvore de arquivos, a própria rede, os próprios PIDs) e cgroups (limites de CPU e memória). Não há segundo kernel — o contêiner usa o kernel da máquina hospedeira. Por isso ele sobe em milissegundos e consome pouca memória.
🧠 Você sabia?
As tecnologias que o Docker usa — namespaces e cgroups — já existiam no kernel Linux anos antes de o Docker aparecer. A inovação do Docker não foi o isolamento, e sim o empacotamento: um formato de imagem em camadas, um arquivo de receita (Dockerfile) e um registro público para compartilhar imagens. Ele foi apresentado ao mundo numa palestra-relâmpago de cinco minutos, como projeto interno de uma empresa de hospedagem — e em poucos anos virou o padrão da indústria.
🔬 Investigue
Depois de instalar o Docker (§3), rode docker run --rm alpine uname -r e, em seguida, uname -r na sua própria máquina (no Windows, dentro do WSL). Compare. Depois rode docker run --rm alpine cat /etc/os-release e cat /etc/os-release. O que é igual e o que é diferente? A resposta é a definição prática de contêiner: mesmo kernel, sistema de arquivos diferente.
Quatro palavras que você vai usar o tempo todo. Vale fixar a diferença agora:
Conceito
O que é
Analogia
Imagem
pacote imutável, em camadas, com sistema de arquivos + comando inicial
a receita e os ingredientes lacrados
Contêiner
uma imagem em execução (um processo isolado); pode haver vários da mesma imagem
o bolo assado — cada um independente
Volume
área de disco gerenciada pelo Docker que sobrevive ao contêiner
a geladeira: o que está nela não some quando o forno desliga
Rede
rede virtual onde contêineres se enxergam pelo nome
o ramal interno da empresa
Três consequências práticas dessas definições:
Tudo o que um contêiner grava dentro de si mesmo é descartável. Ao remover o contêiner, os arquivos somem. Dados que precisam sobreviver (o diretório de dados do MySQL, uploads) vão para um volume.
Uma imagem nunca muda. Para mudar, você constrói outra e dá outra tag (unieventos-api:1.0.1). Isso é o que permite voltar atrás em um deploy: basta rodar a tag anterior.
Contêineres na mesma rede se resolvem pelo nome. Dentro de um docker compose, a API alcança o banco em db:3306, não em localhost:3306 — localhost dentro de um contêiner é o próprio contêiner. Esse detalhe causa o erro mais comum do capítulo (§🐛).
Cada instrução do Dockerfile gera uma camada. O Docker guarda as camadas em cache e só reconstrói a partir da primeira que mudou. Por isso a ordem das instruções importa: o que muda pouco (instalar dependências) vem antes do que muda a cada commit (copiar o código-fonte). Você vai ver isso em ação na §5.
Docker Desktop (Windows e macOS): aplicativo com interface gráfica que traz o Docker Engine dentro de uma máquina virtual Linux leve. No Windows ele usa o WSL 2 — instale o WSL antes (wsl --install em um PowerShell de administrador, reinicie) e marque a integração com a sua distribuição nas configurações do Docker Desktop.
Docker Engine (Linux, inclusive o VPS): só o motor e a linha de comando, sem interface gráfica. É o que vamos usar no servidor.
Por padrão só o root fala com o daemon do Docker. Adicione o seu usuário ao grupo docker para não precisar de sudo a cada comando:
Terminal
sudousermod-aGdocker$USER
newgrpdocker# aplica o grupo na sessão atual (ou saia e entre de novo)
docker--version
dockercomposeversion
⚠️ Atenção
Estar no grupo docker equivale a ter root na máquina — quem pode subir um contêiner pode montar / dentro dele. No VPS, só adicione ao grupo o usuário que faz deploy (o deploy que você criou no Capítulo 06), nunca crie contas extras "só para testar".
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
Leia as três primeiras linhas: o Docker não achou a imagem localmente, baixou do Docker Hub (o registro público padrão) e só então rodou um contêiner a partir dela. É o ciclo que se repete para toda imagem.
Vamos praticar com uma imagem útil — o nginx — antes de construir a nossa:
Terminal
# sobe um contêiner em segundo plano (-d), com nome, mapeando a porta 8080 do host para a 80 do contêiner
dockerrun-d--nameteste-nginx-p8080:80nginx:1.28-alpine
# lista os contêineres em execução (com -a, também os parados)
dockerps
# acompanha os logs (Ctrl+C para sair — o contêiner continua rodando)
dockerlogs-fteste-nginx
# abre um shell DENTRO do contêiner (-i interativo, -t com terminal)
dockerexec-itteste-nginxsh
Dentro do shell, olhe em volta e saia:
Terminal
ls/usr/share/nginx/html# os arquivos que o nginx está servindo
psaux# só o nginx: um processo por contêinerexit
Abra http://localhost:8080 no navegador — a página de boas-vindas do nginx vem do contêiner. Agora pare e limpe:
Terminal
dockerstopteste-nginx# envia SIGTERM; depois de 10 s, SIGKILL
dockerrmteste-nginx# remove o contêiner (parado)
dockerimages# imagens baixadas/construídas, com tamanho
dockerrminginx:1.28-alpine# remove a imagem, se não quiser mais
Os comandos que você vai digitar mais na vida:
Comando
Faz
docker ps -a
lista todos os contêineres, inclusive parados (e por que pararam: Exited (1))
docker logs --tail 100 -f nome
últimas 100 linhas do log e segue
docker exec -it nome sh
shell dentro do contêiner (imagens Alpine não têm bash)
docker inspect nome
tudo sobre o contêiner em JSON: IP, volumes, variáveis, estado do healthcheck
docker system df
quanto disco imagens, contêineres e volumes ocupam
docker system prune
remove contêineres parados, redes sem uso e cache de build
💡 Dicadocker runcria e inicia um contêiner novo a cada vez. Se você rodar duas vezes o mesmo docker run --name teste-nginx, a segunda falha com Conflict. The container name "/teste-nginx" is already in use. Para voltar a rodar um contêiner parado, use docker start teste-nginx.
Um Dockerfile é a receita da imagem: uma sequência de instruções que o docker build executa de cima para baixo. Este é o da unieventos-api, completo:
Dockerfile
# unieventos-api/Dockerfile# 1. Imagem base: Node 22 LTS sobre Alpine Linux (pequena: ~50 MB comprimida)FROMnode:22-alpine# 2. Metadado que liga a imagem ao repositório no GitHub (aparece no GHCR)LABELorg.opencontainers.image.source="https://github.com/seu-usuario/unieventos-api"# 3. Em produção o Express desliga detalhes de depuração e algumas libs ficam mais rápidasENVNODE_ENV=production
# 4. Diretório de trabalho dentro da imagem; todas as instruções seguintes rodam aquiWORKDIR/app# 5. Copia SÓ os manifestos primeiro: se eles não mudaram, a camada do npm ci vem do cacheCOPYpackage.jsonpackage-lock.json./
# 6. Instala exatamente o que está no lockfile, sem devDependencies (vitest, eslint, supertest)RUNnpmci--omit=dev
# 7. Agora o código. --chown entrega os arquivos ao usuário "node", que já existe na imagemCOPY--chown=node:node..
# 8. A partir daqui nada roda como root — se a API for invadida, o invasor não é rootUSERnode# 9. Documenta a porta (não abre nada sozinho; quem abre é o -p do run ou o ports: do compose)EXPOSE3000# 10. Comando inicial, na forma exec (array): o node vira o processo principal e recebe sinaisCMD["node","src/server.js"]
Cada linha tem um motivo:
FROM node:22-alpine — a tag fixa a versão maior do Node (22) e a distribuição (Alpine). Sem tag, node significa node:latest, e "latest" hoje pode ser outra versão amanhã.
COPY package.json package-lock.json antes de COPY . . — é o truque de cache da §2: mudar um arquivo em src/ invalida só a camada 7 em diante; a camada 6 (npm ci, a mais lenta) continua no cache.
npm ci --omit=dev — ci instala o que está travado no package-lock.json e falha se ele estiver dessincronizado; --omit=dev deixa de fora tudo o que só serve para desenvolver e testar. A imagem fica menor e com menos superfície de ataque.
USER node — a imagem oficial já traz um usuário sem privilégios chamado node. Tudo o que vem depois (inclusive o CMD) roda como ele.
CMD ["node", "src/server.js"] e não CMD npm start — na forma shell (string) ou passando pelo npm, o processo principal do contêiner (PID 1) é um shell ou o npm, e o SIGTERM que o docker stop envia não chega ao Node. Na forma exec, o Node é o PID 1.
Há um detalhe do Linux aqui: o processo de PID 1 ignora sinais para os quais não instalou um tratador. Se o Node for PID 1 sem tratar SIGTERM, o docker stop espera 10 segundos e mata o processo com SIGKILL — no meio de uma requisição, se houver uma. Trate o sinal no src/server.js:
JavaScript
// src/server.js — trecho: adicione depois do app.listen(...)functionencerrar(sinal){console.log(`${sinal} recebido — parando de aceitar conexões`)servidor.close(()=>process.exit(0))// termina as requisições em andamento e saisetTimeout(()=>process.exit(1),8000).unref()// se algo travar, sai de qualquer jeito antes do SIGKILL}process.on('SIGTERM',()=>encerrar('SIGTERM'))process.on('SIGINT',()=>encerrar('SIGINT'))
(servidor é a constante devolvida por app.listen, como no server.js da Aula 13 do Nível 3.) Se não quiser mexer no código, init: true no compose coloca um mini-supervisor como PID 1 que encaminha os sinais — as duas coisas juntas são o ideal.
COPY . . copia tudo que está no diretório do projeto para dentro da imagem — inclusive node_modules da sua máquina (pesado e possivelmente compilado para outro sistema), .git e, pior, o .env com senhas. O .dockerignore funciona como o .gitignore do build:
⚠️ Atenção
Uma imagem é um arquivo que você vai enviar para um registro. Se o .env entrar nela, qualquer pessoa com acesso à imagem tem as suas senhas — mesmo que você "apague" o arquivo em uma camada posterior, a camada anterior continua lá. Configuração entra no contêiner em tempo de execução (variáveis de ambiente), nunca em tempo de build.
Repita o docker build sem mudar nada: todas as camadas vêm do cache (CACHED) e o build termina em um segundo. Edite qualquer arquivo em src/ e construa de novo: só as camadas 7 a 10 rodam. Esse é o cache funcionando.
Para rodar, a API precisa das variáveis de ambiente e de um MySQL para conectar. Na §7 o compose resolve os dois; por enquanto, aponte para o MySQL da sua máquina:
host.docker.internal é o nome pelo qual o contêiner alcança a sua máquina (no Docker Desktop ele existe sozinho; no Linux, o --add-host cria). Em outro terminal, curl http://localhost:3000/health deve responder {"status":"ok"}.
🔎 Por baixo do capô
A imagem oficial node existe em três sabores: node:22 (Debian completo, ~1 GB), node:22-slim (Debian mínimo, ~200 MB) e node:22-alpine (Alpine Linux, ~150 MB com o Node). Alpine usa a biblioteca C musl em vez da glibc; para código JavaScript puro isso é indiferente, mas módulos nativos (sharp, bcrypt) às vezes exigem compilação ou a variante slim. Se um npm ci falhar dentro do Alpine com erro de compilação, troque para node:22-slim antes de perder uma tarde.
O site tem um problema diferente da API: para construir o dist/ você precisa de Node, npm e todas as devDependencies; para servir o dist/, você precisa só de um servidor de arquivos estáticos. Um build multi-stage usa duas imagens base no mesmo Dockerfile — a primeira constrói, a segunda serve — e só a segunda vira a imagem final:
Dockerfile
# unieventos-web/Dockerfile# ---- Estágio 1: construir o site (precisa de Node e de todas as dependências) ----FROMnode:22-alpineASbuildWORKDIR/appCOPYpackage.jsonpackage-lock.json./
RUNnpmci
COPY..
# VITE_* é embutida no JavaScript em tempo de BUILD — por isso é um ARG, não uma variável de execuçãoARGVITE_API_URL
ENVVITE_API_URL=$VITE_API_URLRUNnpmrunbuild
# ---- Estágio 2: servir o resultado (só nginx + arquivos estáticos) ----FROMnginx:1.28-alpineCOPY--from=build/app/dist/usr/share/nginx/html
COPYnginx.conf/etc/nginx/conf.d/default.conf
EXPOSE80
nginx
# unieventos-web/nginx.confserver{listen80;server_name_;root/usr/share/nginx/html;indexindex.html;# SPA: qualquer rota que não seja um arquivo real devolve o index.html, e o Vue Router assumelocation/{try_files$uri$uri//index.html;}# os arquivos em /assets/ têm hash no nome; podem ficar em cache por muito tempolocation/assets/{expires1y;add_headerCache-Control"public,immutable";}}
A imagem final tem uns 50 MB — o Node, o npm e os 300 MB de node_modules ficaram no estágio build, que é descartado. Abra http://localhost:8080, navegue até uma rota interna (/eventos/1) e dê F5: o try_files garante que o nginx devolva o index.html em vez de 404.
💡 Dica
Mudar VITE_API_URL exige reconstruir a imagem — o valor foi substituído dentro do JavaScript pelo Vite. É a mesma regra que você viu ao publicar o front na Aula 15 do Nível 3: variável VITE_* é de build, não de execução.
Subir dois contêineres na mão, com rede, volume, variáveis e ordem certa, é chato e propenso a erro. O Compose descreve tudo isso em um arquivo YAML e sobe (ou derruba) o conjunto com um comando.
# unieventos-api/compose.yaml — ambiente de desenvolvimento: API + MySQLservices:api:build:.# constrói a partir do Dockerfile desta pastaimage:ghcr.io/seu-usuario/unieventos-api:devports:-"3000:3000"env_file:.env# todas as variáveis do .env entram no contêinerenvironment:DB_HOST:db# sobrescreve o .env: aqui o MySQL se chama "db"DB_PORT:3306depends_on:db:condition:service_healthy# só sobe quando o healthcheck do banco passarhealthcheck:test:["CMD","wget","-qO-","http://127.0.0.1:3000/health"]interval:30stimeout:5sretries:3start_period:15sinit:true# PID 1 minimalista que encaminha sinais ao noderestart:unless-stopped# volta sozinho depois de um reboot do hostdb:image:mysql:8.4environment:MYSQL_ROOT_PASSWORD:${DB_ROOT_PASSWORD}MYSQL_DATABASE:${DB_NAME}MYSQL_USER:${DB_USER}MYSQL_PASSWORD:${DB_PASSWORD}ports:-"127.0.0.1:3306:3306"# só a sua máquina alcança; útil para o Workbench/DBeavervolumes:-dados-mysql:/var/lib/mysql# os dados sobrevivem a down/up e a novas imagenshealthcheck:test:["CMD","mysqladmin","ping","-h","127.0.0.1","--silent"]interval:10stimeout:5sretries:10start_period:30srestart:unless-stoppedvolumes:dados-mysql:
Texto
# unieventos-api/.env — nunca commitado; copie de .env.example
NODE_ENV=production
PORT=3000
DB_HOST=localhost
DB_PORT=3306
DB_USER=unieventos
DB_PASSWORD=troque-esta-senha
DB_ROOT_PASSWORD=troque-esta-tambem
DB_NAME=unieventos
FIREBASE_PROJECT_ID=unieventos-xxxxx
FIREBASE_SERVICE_ACCOUNT_BASE64=cole-aqui-o-json-em-base64
CORS_ORIGEM_PERMITIDA=http://localhost:5173
O que cada parte faz:
${DB_NAME} no YAML — o Compose lê o .env do diretório e substitui as variáveis no próprio arquivo. Assim a senha do MySQL e a senha que a API usa são a mesma variável, sem duplicar.
env_file + environment — env_file injeta todo o .env no contêiner da API; environment sobrescreve o que precisa ser diferente dentro da rede do Compose (DB_HOST=db).
MYSQL_USER/MYSQL_PASSWORD — a imagem oficial cria esse usuário com acesso total ao MYSQL_DATABASE. É por isso que DB_USERnão pode ser root: a imagem se recusa a usar essas variáveis para o root (o erro exato está na §🐛).
healthcheck + depends_on: condition: service_healthy — sem isso a API sobe antes de o MySQL aceitar conexões e morre com ECONNREFUSED. O mysqladmin ping em 127.0.0.1 força uma conexão TCP, que só funciona quando o servidor definitivo está de pé (durante a inicialização o MySQL sobe um servidor temporário só por socket).
volumes: dados-mysql — um volume nomeado. docker compose down mantém; docker compose down -v apaga.
restart: unless-stopped — o Docker reinicia o contêiner se ele cair e depois de um reboot, a menos que você o tenha parado de propósito.
dockercomposeup-d--build# constrói a imagem da API (se mudou) e sobe tudo em segundo plano
dockercomposeps# estado de cada serviço, inclusive o healthcheck
dockercomposelogs-fapi# logs só da API (sem o nome: de todos)
dockercomposerun--rmapinpmrunmigrar# comando avulso em um contêiner descartável
dockercomposeexecdbmysql-uroot-punieventos# cliente MySQL dentro do contêiner do banco
dockercomposerestartapi# reinicia só a API
dockercomposedown# para e remove contêineres e rede; volumes ficam
dockercomposedown-v# idem, e APAGA os volumes (os dados do banco)
⚠️ Atençãodocker compose down -v apaga o banco inteiro sem perguntar. Antes de rodar em qualquer máquina que não seja de desenvolvimento, faça um mysqldump (Capítulo 08). No VPS, prefira nunca usar -v.
Quando a mesma variável aparece em mais de um lugar, o Compose aplica esta ordem, da menor para a maior prioridade: valor dentro do Dockerfile (ENV) → env_file → environment → -e na linha de comando. É por isso que DB_HOST=localhost no .env (bom para rodar a API fora do Docker) não atrapalha o DB_HOST: db do environment.
8. Publicando a imagem no GitHub Container Registry¶
Construir a imagem no VPS funciona, mas gasta CPU e memória de um servidor pequeno e exige o código-fonte lá. O fluxo profissional é: construir uma vez (na sua máquina ou, no Capítulo 09, no CI), enviar para um registro, e o servidor só baixa. O GitHub oferece um registro gratuito para repositórios públicos: o GHCR (ghcr.io).
Crie um token clássico em Settings → Developer settings → Personal access tokens → Tokens (classic), com os escopos write:packages e read:packages. Guarde-o em uma variável de ambiente do terminal (nunca em arquivo do projeto) e faça login:
A imagem aparece em Packages no seu perfil. Por padrão ela é privada: para o VPS baixá-la sem login, abra a página do pacote → Package settings → Change visibility → Public. O LABEL org.opencontainers.image.source que você colocou no Dockerfile faz o pacote aparecer também na página do repositório.
⚠️ Atenção
Se você usa um Mac com chip Apple (ARM), a imagem construída com docker build é linux/arm64 — e o VPS é linux/amd64. O pull até funciona, mas o contêiner morre com exec format error. Construa para a arquitetura do servidor: docker buildx build --platform linux/amd64 -t ghcr.io/seu-usuario/unieventos-api:1.0.0 --push .
Um resumo do que separa um Dockerfile de tutorial de um Dockerfile de produção:
Prática
Por quê
Como
Não rodar como root
invasão do processo não vira invasão do host
USER node; COPY --chown
Imagem pequena
menos download, menos vulnerabilidades, deploy mais rápido
-alpine, --omit=dev, multi-stage, .dockerignore
Tags fixas
reprodutibilidade: o mesmo build hoje e daqui a um ano
node:22-alpine, mysql:8.4, nunca latest em produção
Configuração por ambiente
a mesma imagem serve dev, teste e produção
variáveis de ambiente, nunca .env dentro da imagem
Um processo por contêiner
logs, reinício e escala independentes
API em um, banco em outro, nginx em outro
Logs no stdout
docker logs e o Capítulo 10 dependem disso
console.log/pino sem arquivo
Healthcheck
orquestrador sabe se o serviço está vivo, não só se o processo existe
HEALTHCHECK ou healthcheck: no compose
Dados em volume
contêiner é descartável; dados não
volumes: para /var/lib/mysql, uploads
🔎 Por baixo do capô
Uma tag como node:22-alpine é um ponteiro móvel: aponta para a última 22.x.y publicada. Para congelar de verdade, use o digest: FROM node:22-alpine@sha256:… (o valor aparece em docker images --digests). Em projetos grandes é comum um bot (Dependabot, Renovate) abrir pull requests atualizando esse digest — assim a atualização é uma decisão revisada, não uma surpresa no próximo build.
🚀 Passo a passo — UniEventos API + MySQL com docker compose, local e no VPS¶
Ao final destes passos a unieventos-api estará rodando em contêiner na sua máquina e no VPS do Capítulo 06, a partir da mesma imagem publicada no GHCR, com o MySQL também em contêiner e os dados em volume.
Passo 1 — Dockerfile, .dockerignore e encerramento limpo¶
Crie Dockerfile e .dockerignore na raiz de unieventos-api com o conteúdo da §5 (troque seu-usuario no LABEL) e adicione o tratamento de SIGTERM ao src/server.js. Construa uma vez para validar:
Terminal
cdunieventos-api
dockerbuild-tunieventos-api:dev.
Se o build falhar em npm ci, o package-lock.json está dessincronizado do package.json: rode npm install fora do Docker, commite o lockfile e tente de novo.
Crie compose.yaml com o conteúdo da §7. Atualize o .env com DB_USER=unieventos, DB_PASSWORD, DB_ROOT_PASSWORD e DB_NAME=unieventos; espelhe as chaves (sem valores) no .env.example. Confirme que .env está no .gitignoree no .dockerignore.
Espere a coluna STATUS do db mostrar healthy (até 30 s na primeira vez, porque o MySQL inicializa o diretório de dados). A API só sobe depois disso. Aplique as migrations e confira:
É o mesmo VPS do Capítulo 06 — o alias meuvps do seu ~/.ssh/config, que entra como usuário deploy. Não crie máquina nova: o nginx, o firewall e o certificado que você configurou lá continuam valendo, e o domínio seudominio.dev é o mesmo do Capítulo 04. Instale o Docker (§3) e libere o usuário deploy:
Crie /srv/unieventos-api/compose.prod.yaml. A diferença para o arquivo de desenvolvimento: usa a imagem publicada em vez de build:, expõe a API só em 127.0.0.1 (o nginx é quem fala com o mundo) e não expõe o MySQL:
Crie o .env de produção ao lado (com senhas diferentes das de desenvolvimento e CORS_ORIGEM_PERMITIDA apontando para o domínio do site), com permissão restrita:
Terminal
# /srv pertence ao root: sem o sudo, o mkdir responde "Permission denied".
sudomkdir-p/srv/unieventos-api
sudochowndeploy:deploy/srv/unieventos-api
cd/srv/unieventos-api
nano.env
chmod600.env
⚠️ Atenção
O Docker manipula o firewall do kernel diretamente e passa por cima do ufw que você configurou no Capítulo 06: um ports: - "3000:3000" abre a porta 3000 para a internet inteira, mesmo com ufw deny 3000. Por isso o compose.prod.yaml usa "127.0.0.1:3000:3000" — só processos do próprio VPS (o nginx) alcançam a API — e não publica porta nenhuma do MySQL.
# O compose lê o .env sozinho; o seu shell, não. Carregue-o antes,# senão o -p fica sem valor, vira prompt e a senha sai da primeira linha do .sql.set-a;../.env;set+a
dockercompose-fcompose.prod.yamlexec-Tdb\mysql-uroot-p"$DB_ROOT_PASSWORD"unieventos<unieventos-backup.sql
O nginx do Capítulo 06 continua fazendo proxy para 127.0.0.1:3000 — não precisa mudar nada nele.
No VPS: docker compose -f compose.prod.yaml ps mostra api e db com healthy.
Da sua máquina: curl https://api.seudominio.dev/health responde {"status":"ok"} e curl https://api.seudominio.dev/api/eventos devolve a lista.
docker compose -f compose.prod.yaml logs --tail 20 api mostra as requisições que você acabou de fazer.
Reinicie o VPS (sudo reboot), espere um minuto e repita o item 2 — restart: unless-stopped trouxe os dois contêineres de volta sem você fazer nada.
Resultado esperado: a mesma imagem ghcr.io/seu-usuario/unieventos-api:1.0.0 rodando no seu notebook e no VPS, com o unieventos-web publicado apontando para https://api.seudominio.dev e funcionando de ponta a ponta.
A1. Explique a diferença entre imagem e contêiner em duas frases. Depois responda: se você rodar docker run -d nginx:1.28-alpine três vezes, quantas imagens e quantos contêineres existem?
A2. Preveja a saída. Você roda docker run -d --name a nginx:1.28-alpine, depois docker stop a, depois docker ps. O contêiner a aparece? E em docker ps -a? O que aparece na coluna STATUS?
A3. Um colega diz: "rodei docker compose down -v e o banco sumiu, o Docker perdeu meus dados". Explique o que aconteceu e qual comando ele deveria ter usado.
A4. Reordene estas instruções de Dockerfile para aproveitar o cache ao máximo e justifique: COPY . ., RUN npm ci --omit=dev, FROM node:22-alpine, COPY package*.json ./, WORKDIR /app, CMD ["node","src/server.js"].
A5. Dentro do contêiner da API, DB_HOST=localhost falha e DB_HOST=db funciona. Explique o que localhost significa dentro de um contêiner e de onde vem o nome db.
B1. Adicione ao compose.yaml um terceiro serviço, adminer (imagem oficial adminer, porta 8080:8080, variável ADMINER_DEFAULT_SERVER=db), e use-o para navegar nas tabelas do UniEventos pelo navegador.
Resultado esperado: http://localhost:8080 mostra a tela de login do Adminer; com servidor db, usuário e senha do .env, você vê as tabelas eventos, inscricoes e migrations_executadas.
Dica
O Adminer está na mesma rede do Compose, então o "servidor" no formulário é o nome do serviço (db), não localhost. Não é preciso depends_on — ele só conecta quando você preenche o formulário.
B2. Faça um backup do volume dados-mysql sem parar o banco, usando um contêiner descartável que monta o volume e compacta o conteúdo em um .tgz na sua pasta atual.
Resultado esperado: um arquivo dados-mysql.tgz de alguns MB no diretório do projeto; tar tzf dados-mysql.tgz | head lista arquivos do MySQL.
Dica
docker volume ls mostra o nome real do volume (o Compose prefixa com o nome da pasta: unieventos-api_dados-mysql). O comando tem a forma docker run --rm -v NOME_DO_VOLUME:/dados -v "$PWD":/backup alpine tar czf /backup/dados-mysql.tgz -C /dados .. Para um backup consistente de verdade, prefira o mysqldump do Capítulo 08 — este exercício é sobre volumes.
B3. Derrube o MySQL de propósito (docker compose stop db) com a API rodando, faça uma requisição a /api/eventos e observe o log da API. Depois suba o banco de novo e repita a requisição.
Resultado esperado: com o banco parado, a API responde 500 (ou 503, se o seu tratador de erros distingue) e o log mostra o erro de conexão; com o banco de volta, a requisição funciona sem reiniciar a API — o pool do mysql2 reconecta sozinho.
Dica
docker compose logs -f api em um terminal, curl -i http://localhost:3000/api/eventos em outro. Se a API morreu junto com o banco, procure um process.exit ou uma exceção não tratada no seu código de conexão.
B4. Descubra quanto a imagem da API pesa e quanto pesaria sem --omit=dev e sem Alpine. Construa três variantes (node:22-alpine com --omit=dev, node:22-alpine sem --omit=dev, node:22 com --omit=dev) com tags diferentes e compare em docker images.
Resultado esperado: uma tabela com os três tamanhos, a variante Alpine + --omit=dev sendo a menor por larga margem.
Dica
Use docker build -f com Dockerfiles alternativos (Dockerfile.debian, Dockerfile.dev) ou passe --build-arg. docker history nome:tag mostra o tamanho de cada camada — a do npm ci é a que muda.
C1. Faça a API recarregar sozinha dentro do contêiner quando você edita um arquivo em src/, sem reconstruir a imagem. Crie um compose.override.yaml (o Compose o mescla automaticamente com compose.yaml) que monte a pasta do projeto dentro do contêiner e troque o comando por node --watch src/server.js.
Dica
Três problemas para resolver, nesta ordem: (1) o node_modules da sua máquina não deve sobrescrever o do contêiner — monte um volume anônimo em /app/node_modules; (2) --watch precisa das devDependencies? Não, mas o vitest sim — se quiser rodar testes dentro do contêiner, o override precisa de uma imagem construída sem --omit=dev (use um target ou um Dockerfile.dev); (3) NODE_ENV=production desliga coisas úteis em desenvolvimento — sobrescreva no override.
A imagem da API construída no Passo a passo pesa quanto? docker images ghcr.io/seu-usuario/unieventos-api responde. Se passou de 150 MB, algo desnecessário entrou: devDependencies, cache do npm, arquivos que o .dockerignore deveria barrar. Reduza-a até ficar abaixo de 150 MB sem quebrar o /health nem o npm run migrar.
Critérios de pronto
docker images mostra a imagem final com menos de 150 MB.
docker compose up -d com a imagem reduzida sobe, passa no healthcheck e GET /api/eventos responde.
docker history da imagem não mostra nenhuma camada com node_modules de desenvolvimento.
Um parágrafo no README.md registra o tamanho antes e depois e o que foi removido.
Pistas
docker history --no-trunc nome:tag mostra o tamanho de cada camada; comece pela maior.
O npm ci deixa um cache em ~/.npm dentro da imagem; npm cache clean --force na mesma instrução RUN (encadeada com &&) evita que ele vire camada.
Confira se test/, coverage/, .git/ e docs/ estão no .dockerignore — docker build mostra o tamanho do contexto enviado na primeira linha.
Se ainda estiver acima, compare node:22-alpine com node:22-slim — e verifique quais dependências de produção são realmente necessárias.
A cafe-cerrado-api do Nível 2 é mais simples que a do UniEventos: um Express 5 que serve a pasta public/ e expõe /api/produtos, gravando em um arquivo JSON. Justamente por gravar em arquivo ela tem um problema que a unieventos-api não tem: se o JSON ficar dentro do contêiner, cada docker compose up de uma imagem nova zera o cardápio. Empacote-a de forma que os dados sobrevivam.
Critérios de pronto
Dockerfile com node:22-alpine, npm ci --omit=dev, USER node e forma exec no CMD.
compose.yaml com um volume nomeado montado exatamente na pasta onde o JSON é gravado, e nada mais.
Criar um produto pela API, rodar docker compose down && docker compose up -d e o produto continuar lá.
O usuário node consegue escrever no diretório do volume (o erro EACCES é o obstáculo esperado).
Pistas
Descubra o caminho absoluto em que a API grava o JSON dentro do contêiner (WORKDIR + caminho relativo).
Um volume nomeado novo é criado como root. Ou você cria o diretório e faz chown node:node no Dockerfileantes de declarar o VOLUME, ou monta com user: "1000:1000" no compose.
Se a API lê o arquivo de exemplo do repositório na primeira execução, ela precisa copiá-lo para o volume quando o volume estiver vazio — ou o Dockerfile copia o JSON inicial para o diretório antes de o volume ser montado (o Docker copia o conteúdo pré-existente do diretório para um volume nomeado vazio na primeira montagem).
Teste o cenário de "imagem nova": mude qualquer coisa no código, up -d --build, e confira que o produto criado continua.
Hoje o site chama a API em outra origem e o CORS precisa liberar o domínio do front. Se o mesmo nginx que serve o site fizer proxy de /api/ para a API, front e back passam a ter a mesma origem — e o CORS deixa de existir. Monte um compose.yaml com três serviços — web (imagem multi-stage da §6), api e db — em que docker compose up -d --build sobe o UniEventos completo em http://localhost:8080, e nenhuma requisição do navegador sai para outra porta.
Critérios de pronto
docker compose up -d --build na raiz de um repositório que contém unieventos-web/ e unieventos-api/ sobe os três serviços.
http://localhost:8080 abre o site; a aba Rede do DevTools mostra GET http://localhost:8080/api/eventos (mesma origem).
O site foi construído com VITE_API_URL vazio ou igual à própria origem, e o nginx.conf do web tem um location /api/ fazendo proxy para http://api:3000.
A porta 3000 não está publicada no host — só o web alcança a API.
O README.md explica em até 10 linhas por que o CORS deixou de ser necessário.
Pistas
docker compose aceita build: context: ./unieventos-api para construir a partir de subpastas; o Dockerfile do web precisa de args: VITE_API_URL: "" na seção build:.
No nginx, location /api/ { proxy_pass http://api:3000; } — atenção à barra final em proxy_pass: com ou sem ela o caminho repassado muda. Teste as duas formas e observe o log da API.
O nginx resolve o nome api na hora de subir; se a API ainda não existir, ele falha com host not found in upstream. depends_on resolve a ordem.
O helmet da API pode enviar cabeçalhos que conflitam com os do nginx (X-Frame-Options, CSP). Se o site quebrar, olhe os cabeçalhos da resposta antes de mexer no código.
Escreva Dockerfile e .dockerignore seguindo a §5 (imagem node:22-alpine, npm ci --omit=dev, USER node, forma exec no CMD, LABEL apontando para o seu repositório).
Escreva compose.yaml com a API e o banco (MySQL 8.4 ou Postgres, conforme o seu projeto), volume nomeado, healthcheck e depends_on com condition: service_healthy.
Atualize .env.example com todas as variáveis que o compose precisa e adicione ao README.md uma seção "Rodando com Docker" de no máximo 10 linhas: clonar, copiar .env.example para .env, docker compose up -d --build, migrar, testar /health.
Publique a imagem no GHCR com a tag 1.0.0 e torne o pacote público.
Critério de pronto: um colega (ou você, em outra pasta) consegue clonar o repositório, seguir só o README.md e ter curl http://localhost:3000/health respondendo {"status":"ok"} — sem instalar Node nem banco na máquina. O .env não está no repositório nem na imagem (docker run --rm sua-imagem cat .env deve falhar).
Guarde no seu repositório: commit + push, junto com o link do pacote no GHCR.
[ ] docker run hello-world funciona sem sudo na sua máquina e no VPS.
[ ] Dockerfile da API constrói sem erro; docker images mostra a imagem abaixo de 200 MB; docker history não mostra .env nem node_modules de desenvolvimento.
[ ] .dockerignore barra node_modules, .env, .git e o JSON da conta de serviço do Firebase.
[ ] docker compose up -d --build sobe API + MySQL; docker compose ps mostra os dois healthy.
[ ] docker compose down seguido de up -d preserva os dados (volume nomeado).
[ ] Imagem publicada em ghcr.io/seu-usuario/unieventos-api:1.0.0, pacote público.
[ ] No VPS, compose.prod.yaml roda a imagem do GHCR com a API em 127.0.0.1:3000 e sem porta do MySQL publicada.
[ ] https://api.seudominio.dev/health responde {"status":"ok"} depois de um sudo reboot do VPS, sem intervenção.
[ ] Dockerfile multi-stage do unieventos-web gera uma imagem nginx de ~50 MB que serve o site com fallback para index.html.
Docker and ufw — por que o Docker passa por cima do firewall e como publicar portas só em 127.0.0.1.
No Capítulo 08 o MySQL sai do VPS: você vai levar o banco para um serviço gerenciado (Supabase, Neon ou um MySQL na nuvem), com migrations versionadas, seed, backup e restauração — e descobrir por que a região do banco importa tanto quanto a região da API.
Explicar o que um banco gerenciado entrega além do banco em si (backup automático, alta disponibilidade, atualizações, métricas) e quando ele não compensa.
Criar um Postgres no Supabase, ler o painel e escolher entre conexão direta, pooler de sessão e pooler de transação, sabendo o que cada um resolve.
Conectar a API ao banco na nuvem com pg (Postgres) ou mysql2/promise (MySQL), sempre com pool e TLS verificado, e configurar tudo por uma única variável DATABASE_URL.
Traduzir um schema MySQL para Postgres (tipos, chaves, placeholders, RETURNING) sem quebrar o código da API.
Versionar mudanças de schema com migrations em SQL numeradas e escrever um seed idempotente.
Fazer backup e restauração com pg_dump/psql e mysqldump/mysql, automatizar com cron e provar que o backup restaura.
Reconhecer os erros clássicos de conexão na nuvem: IPv6, TLS, limite de conexões e RLS.
[ ] unieventos-api na forma da Aula 13 do Nível 3: camadas (controllers, services, repositories), src/config/index.js validando o ambiente com zod, migrations/*.sql e npm run migrar.
[ ] Docker e docker compose funcionando (Capítulo 07) — você vai usá-los para testar restaurações sem sujar nada.
[ ] Conta no GitHub (Capítulo 02) — o login do Supabase e do Neon pode ser feito por ela.
[ ] Cliente de linha de comando do banco: psql (pacote postgresql-client) e/ou mysql + mysqldump (pacote mysql-client).
[ ] Um .env local funcionando, e a certeza de que ele não está no Git (Capítulo 02) nem na imagem (Capítulo 07).
No Capítulo 07 a unieventos-api e o MySQL viraram contêineres, e o volume dados-mysql guardou os dados no disco do VPS. Funciona — mas repare no que você comprou junto: se aquele VPS morrer, os dados morrem com ele; se o disco encher, o MySQL para; se você quiser rodar a API em dois lugares (um teste no Render e a produção no VPS), são dois bancos diferentes, cada um com uma verdade. Backup, atualização de versão, ajuste de memória e monitoramento também passaram a ser trabalho seu. Hoje o banco sai do servidor e vira um serviço gerenciado — Supabase, Neon ou um MySQL na nuvem —, acessível pela internet, com backup automático e uma URL só sua: você vai conectar a API por TLS, versionar o schema com migrations, popular com um seed e, principalmente, aprender a fazer e testar um backup. No Capítulo 09 o GitHub Actions vai construir a imagem, rodar os testes e publicar tudo sozinho, e um banco gerenciado é o que torna esse deploy automático seguro: a máquina pode ser recriada do zero sem levar os dados junto.
Um banco de dados é o único componente da sua aplicação que não pode ser recriado. O código está no GitHub; a imagem está no GHCR; o servidor você reinstala em vinte minutos com os comandos do Capítulo 06. Os dados, não: se sumirem, sumiram.
Rodar o banco no mesmo VPS da API cria três riscos que só aparecem quando é tarde:
Falha única. O disco do VPS é um só. Provedores baratos usam disco local sem réplica; um problema de hardware leva o servidor inteiro.
Concorrência por recursos. Um pico de acesso faz o Node consumir CPU, o MySQL fica sem CPU, as consultas ficam lentas, as requisições se acumulam, o Node consome mais memória — e o kernel escolhe alguém para matar. Em um VPS de 1 GB, quem morre quase sempre é o banco.
Manutenção esquecida. Atualização de versão, innodb_buffer_pool_size, verificação de backup, monitoramento de espaço. Tudo isso é trabalho contínuo que ninguém faz até dar errado.
Um banco gerenciado é um servidor de banco operado por outra empresa, que entrega para você apenas o endereço e as credenciais. O que vem junto:
Você deixa de fazer
O serviço faz
Cuidado que continua seu
Instalar e atualizar o SGBD
versões corrigidas, patches de segurança
testar a aplicação depois de uma atualização maior
Configurar backup
snapshots automáticos com retenção
conferir que o backup restaura
Dimensionar disco e memória
crescimento automático ou com um clique
acompanhar o consumo e o custo
Monitorar
painel com conexões, consultas lentas, uso de CPU
olhar o painel de vez em quando
O que não muda: modelagem, índices, consultas eficientes e segurança da aplicação continuam sendo responsabilidade sua. Um banco gerenciado com uma consulta sem índice é lento igual.
🧠 Você sabia?
A palavra "nuvem" esconde uma máquina muito concreta. Quando você cria um Postgres gratuito no Supabase ou no Neon, ele nasce em uma região específica — sa-east-1 é São Paulo, us-east-1 é a Virgínia. A luz percorre cerca de 200 km por milissegundo em fibra óptica, e o caminho nunca é reto: de Sinop até a Virgínia e de volta, uma única ida e volta custa por volta de 120 ms, contra uns 20 ms até São Paulo. Uma página que faz 10 consultas em sequência sente essa diferença como um segundo inteiro de espera. Região não é detalhe de cadastro: é decisão de arquitetura.
🔬 Investigue
Meça a latência até três regiões antes de escolher a sua. No terminal, rode ping -c 5 aws-0-sa-east-1.pooler.supabase.com e compare com ping -c 5 aws-0-us-east-1.pooler.supabase.com (se o ping for bloqueado, use curl -o /dev/null -s -w "%{time_connect}\n" https://supabase.com como aproximação). Anote os dois tempos médios. Depois multiplique o maior por 10 — é quanto uma tela que faz 10 consultas sequenciais vai esperar só de rede. Esse número é o argumento para escolher a região mais próxima e para trocar 10 consultas por 1 JOIN.
Os três caminhos que interessam para os projetos desta trilha:
Serviço
Banco
Encaixa bem quando
Supabase
Postgres
você já usou o supabase-js na Aula 12 e quer o mesmo banco também por SQL direto
Neon
Postgres
você quer branches de banco: uma cópia instantânea do banco por pull request
MySQL gerenciado (Aiven, Railway, RDS…)
MySQL 8
o projeto já é MySQL e você não quer traduzir o schema agora
Planos gratuitos existem nos três, com limites que mudam com frequência (tamanho do banco, número de projetos, tempo de retenção de backup, suspensão por inatividade). Confira o plano no site antes de decidir — e nunca coloque um projeto que você quer manter no ar em um plano que suspende o banco depois de uma semana sem uso sem você saber disso.
Três características valem mais que o preço nesta escolha:
Região disponível. Se o serviço não oferece São Paulo, cada consulta vai custar mais de 100 ms.
Compatibilidade de rede. Alguns endereços só existem em IPv6 (§4). Se o seu VPS ou o runner do GitHub Actions só tem IPv4, você precisa do endereço alternativo.
Backup que você controla. Snapshot automático do provedor é ótimo, mas você também precisa de um .sql seu, guardado em outro lugar (§8).
💡 Dica
O Supabase é bem mais que um Postgres: traz autenticação, Storage, Realtime e a API REST automática que você usou na Aula 12 do Nível 3. Neste capítulo usamos só o banco, conectando por SQL como faríamos com qualquer Postgres. As duas formas convivem: o front pode falar com o supabase-js e a sua API Express falar com o mesmo banco por pg.
No painel do Supabase, New project: escolha a organização, dê um nome (unieventos), gere uma senha forte para o banco e escolha a região South America (São Paulo).
⚠️ Atenção
A senha do banco aparece uma vez. Guarde-a no gerenciador de senhas antes de clicar em criar. Se perder, dá para redefinir em Settings → Database → Reset database password, mas isso invalida todas as strings de conexão que já estiverem em uso — inclusive a do servidor em produção.
Enquanto o projeto sobe (leva cerca de um minuto), conheça as três abas que você mais vai usar:
Table Editor — as tabelas em forma de planilha, para conferir dados.
SQL Editor — um terminal SQL no navegador. É onde você roda consultas de conferência.
Settings → Database — as strings de conexão, o certificado TLS e o número de conexões em uso.
Este é o ponto que mais confunde. O Supabase oferece três endereços para o mesmo banco:
Forma
Porta
Para que serve
Conexão direta (db.<ref>.supabase.co)
5432
processos longos, migrations, pg_dump; costuma resolver só em IPv6
Pooler de sessão (...pooler.supabase.com)
5432
APIs que ficam no ar (a nossa): uma conexão por sessão, IPv4
Pooler de transação (mesmo host)
6543
funções serverless, que abrem e fecham conexão a cada requisição
As strings têm esta forma (o <ref> é o identificador do projeto, e repare no usuário diferente no pooler):
Texto
# conexão direta
postgresql://postgres:SUA_SENHA@db.abcdefghijklmnop.supabase.co:5432/postgres
# pooler de sessão (recomendado para a unieventos-api)
postgresql://postgres.abcdefghijklmnop:SUA_SENHA@aws-0-sa-east-1.pooler.supabase.com:5432/postgres
# pooler de transação (serverless)
postgresql://postgres.abcdefghijklmnop:SUA_SENHA@aws-0-sa-east-1.pooler.supabase.com:6543/postgres
O pooler (o Supabase usa o Supavisor) é um intermediário que mantém um punhado de conexões abertas com o Postgres e as empresta aos clientes. Ele existe porque cada conexão do Postgres custa memória de verdade — o servidor cria um processo por conexão — e o limite de um plano gratuito é da ordem de algumas dezenas. Sem pooler, três instâncias da API com max: 10 já ocupam 30 conexões, e a próxima que tentar abrir recebe sorry, too many clients already — inclusive o psql que você usaria para investigar o problema.
A diferença entre os dois modos:
Sessão (5432) — o cliente recebe uma conexão e fica com ela até desconectar. Tudo funciona como no Postgres normal: SET, prepared statements, transações longas. É o modo certo para um servidor Express que sobe uma vez e fica no ar.
Transação (6543) — o cliente recebe uma conexão apenas durante cada transação e a devolve em seguida. Muitas conexões de clientes cabem em poucas conexões reais, mas prepared statements nomeados não sobrevivem entre transações: o erro clássico é prepared statement "s1" already exists. É o modo para código serverless, que sobe e morre a cada requisição.
🔎 Por baixo do capô
A conexão direta de projetos novos costuma ter só endereço IPv6. Se a sua máquina, o seu VPS ou o runner de CI não tiver IPv6, o Node falha com Error: connect ENETUNREACH 2600:1f1c:…:5432 — um erro que parece de firewall, mas é de protocolo. Teste com curl -6 https://ifconfig.co (responde se você tem IPv6) e curl -4 https://ifconfig.co. Por isso o padrão deste capítulo é o pooler de sessão, que atende também em IPv4.
O pg ainda é distribuído como CommonJS. Em um projeto com "type": "module" (o nosso, desde a Aula 07 do Nível 3), você importa o pacote inteiro e pega as classes de dentro:
JavaScript
// src/db/pool.js — pool de conexões com o Postgres na nuvemimport{readFileSync}from'node:fs'importpgfrom'pg'// CommonJS: importe o módulo, não { Pool }import{config}from'../config/index.js'// Certificado da autoridade do provedor, baixado no painel (§4.1).// config.DATABASE_CA guarda o caminho relativo à raiz do projeto (§4.2).constcertificadoDaAutoridade=readFileSync(newURL(`../../${config.DATABASE_CA}`,import.meta.url))exportconstpool=newpg.Pool({connectionString:config.DATABASE_URL,ssl:{ca:certificadoDaAutoridade,rejectUnauthorized:true,// recusa a conexão se o certificado não bater},max:10,// conexões simultâneas DESTE processoidleTimeoutMillis:30_000,// devolve ao servidor conexões ociosas por 30 sconnectionTimeoutMillis:10_000,// falha rápido se o banco não responder em 10 s})// Conexões ociosas podem cair (rede, manutenção do provedor). Sem este tratador,// o erro sobe como exceção não capturada e derruba o processo inteiro.pool.on('error',(erro)=>{console.error('erro em conexão ociosa do pool:',erro.message)})/** Executa uma consulta e devolve só as linhas, avisando quando ela demora demais. */exportasyncfunctionconsultar(sql,parametros=[]){constinicio=Date.now()constresultado=awaitpool.query(sql,parametros)constduracao=Date.now()-inicioif(duracao>200){console.warn(`consulta lenta (${duracao} ms): ${sql.trim().slice(0,70)}`)}returnresultado.rows}
Três decisões dentro desse arquivo merecem explicação:
max: 10 é por processo. Se você roda dois contêineres da API, são 20 conexões no pooler. Some as instâncias antes de escolher o número; em um plano gratuito, max: 5 costuma ser mais do que suficiente para várias pessoas testando ao mesmo tempo.
rejectUnauthorized: true com ca. Sem TLS, a senha do banco viaja em texto puro pela internet. Com TLS mas sem verificar o certificado (rejectUnauthorized: false), você está protegido contra quem só escuta, mas não contra quem se coloca no meio da conversa. O certificado da autoridade fecha essa porta.
O tratador de pool.on('error'). É o item que separa uma API que sobrevive à noite de uma que amanhece morta.
No Supabase: Settings → Database → SSL Configuration → Download certificate. Salve o arquivo como certs/banco-ca.crt na raiz da API. Ele é público (é um certificado, não uma chave), então pode ir para o Git — mas o .dockerignore do Capítulo 07 precisa não barrá-lo, ou a imagem sobe sem o arquivo e a API morre com ENOENT.
Terminal
mkdir-pcerts
# copie o arquivo baixado para certs/banco-ca.crt
ls-lcerts/banco-ca.crt
⚠️ Atenção
Você vai encontrar muito tutorial usando ssl: { rejectUnauthorized: false }. Isso desliga a verificação do certificado: qualquer servidor que responda no endereço passa a ser aceito. Em um trabalho de faculdade ninguém morre por causa disso; em um sistema com dados de pessoas, é uma falha de segurança que tem nome (man-in-the-middle). Use o certificado.
Até agora a API guardava DB_HOST, DB_PORT, DB_USER, DB_PASSWORD e DB_NAME separados. Serviços gerenciados entregam uma URL só, e essa virou a convenção do mercado (Render, Railway, Fly, Heroku e o próprio Docker Compose usam DATABASE_URL). Ajuste o src/config/index.js da Aula 13:
JavaScript
// src/config/index.js — trecho: o banco agora é uma URL únicaconstesquemaDeAmbiente=z.object({NODE_ENV:z.enum(['development','test','production']).default('development'),PORT:z.coerce.number().int().positive().default(3000),// postgresql://usuario:senha@host:porta/bancoDATABASE_URL:z.string().min(1,'DATABASE_URL é obrigatória').startsWith('postgres','DATABASE_URL deve começar com postgres:// ou postgresql://'),DATABASE_CA:z.string().default('certs/banco-ca.crt'),FIREBASE_PROJECT_ID:z.string().min(1,'FIREBASE_PROJECT_ID é obrigatória'),CORS_ORIGEM_PERMITIDA:z.string().min(1,'CORS_ORIGEM_PERMITIDA é obrigatória'),})
Texto
# .env.example — copie para .env e preencha; o .env NUNCA vai para o Git
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://postgres.SEU_REF:SUA_SENHA@aws-0-sa-east-1.pooler.supabase.com:5432/postgres
DATABASE_CA=certs/banco-ca.crt
FIREBASE_PROJECT_ID=unieventos-12345
CORS_ORIGEM_PERMITIDA=http://localhost:5173
💡 Dica
Senha com caractere especial (@, :, /, #) quebra a URL: o @ da senha é confundido com o separador do host. Ou gere uma senha só com letras, números e -/_, ou codifique os especiais (@ vira %40, # vira %23). No Node, encodeURIComponent('mi@nha#senha') mostra o valor certo.
Se o seu projeto continua em MySQL, muda o driver, não a ideia. O mysql2/promise com createPool é o mesmo da Aula 09 do Nível 3, agora com TLS e host remoto. Nesta variante o src/config/index.js mantém as variáveis separadas (DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME) em vez de DATABASE_URL:
JavaScript
// src/db/pool.js — variante para MySQL gerenciado (Aiven, Railway, RDS…)import{readFileSync}from'node:fs'importmysqlfrom'mysql2/promise'import{config}from'../config/index.js'exportconstpool=mysql.createPool({host:config.DB_HOST,port:config.DB_PORT,user:config.DB_USER,password:config.DB_PASSWORD,database:config.DB_NAME,ssl:{ca:readFileSync(newURL('../../certs/banco-ca.pem',import.meta.url)),minVersion:'TLSv1.2',},waitForConnections:true,connectionLimit:10,// equivale ao max do pgqueueLimit:0,// fila ilimitada de quem espera conexãoenableKeepAlive:true,// evita que firewalls derrubem conexões ociosas})/** Executa uma consulta e devolve só as linhas. */exportasyncfunctionconsultar(sql,parametros=[]){const[linhas]=awaitpool.query(sql,parametros)returnlinhas}
mysql.createPool('mysql://usuario:senha@host:porta/banco') também aceita a URL inteira como string; com certificado próprio, os campos separados ficam mais legíveis. Note a diferença de retorno que já apareceu na Aula 09: o mysql2 devolve [linhas, campos], o pg devolve um objeto com .rows. A função consultar esconde isso do resto da aplicação — é o mesmo Adapter da Aula 12.
A restrição uk_eventos_titulo não é decoração: além de impedir dois eventos com o mesmo nome, ela é o que permite ao seed da §7 dizer "insira se não existir" com uma única instrução.
E o repositório, adaptado. É a mesma interface da Aula 13 — quem chama não percebe a troca:
JavaScript
// src/repositories/eventos.repository.postgres.jsimport{pool}from'../db/pool.js'exportfunctioncriarRepositorioDeEventosPostgres(){return{asynclistar({categoria=null,limite=20,deslocamento=0}={}){const{rows}=awaitpool.query(`SELECT * FROM eventos WHERE ($1::text IS NULL OR categoria = $1) ORDER BY data_hora LIMIT $2 OFFSET $3`,[categoria,limite,deslocamento],)returnrows},asyncbuscarPorId(id){const{rows}=awaitpool.query('SELECT * FROM eventos WHERE id = $1',[id])returnrows[0]??null},asynccriar(evento){const{rows}=awaitpool.query(`INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,[evento.titulo,evento.descricao??null,evento.categoria,evento.dataHora,evento.local,evento.vagas??0,evento.imagemUrl??null,],)returnrows[0]},asyncremover(id){constresultado=awaitpool.query('DELETE FROM eventos WHERE id = $1',[id])returnresultado.rowCount>0},}}
Duas sutilezas que economizam meia hora de depuração:
$1::text IS NULL OR categoria = $1 — o Postgres precisa saber o tipo de cada parâmetro para planejar a consulta. Quando o mesmo $1 aparece comparado com NULL e com uma coluna, o ::text tira a ambiguidade; sem ele, o erro é could not determine data type of parameter $1.
resultado.rowCount — o equivalente ao affectedRows do MySQL. É o que diz se o DELETE realmente apagou alguma coisa (para responder 404 em vez de 204).
🧠 Você sabia?
No Postgres, CREATE TABLE, ALTER TABLE e DROP TABLE são transacionais: dentro de BEGIN … ROLLBACK, uma tabela criada some como se nunca tivesse existido. No MySQL, não — qualquer comando de definição de dados confirma a transação em andamento silenciosamente. É por isso que um script de migration que falha no meio deixa o Postgres intacto e o MySQL em um estado híbrido, com metade das mudanças aplicadas. Essa única diferença já justifica o cuidado extra ao escrever migrations para MySQL.
Na Aula 12 do Nível 3 você habilitou Row Level Security nas tabelas do Supabase e escreveu policies — sem elas, o supabase-js recebe data: [] sem erro nenhum — um dos maiores consumidores de tempo de depuração nesta trilha.
Aqui está o detalhe que quase ninguém conta: quando a sua API conecta com a string de conexão do banco, ela entra como o papel postgres, que é dono das tabelas. E o dono da tabela ignora RLS. Ou seja:
Pelo supabase-js, com a chave pública (anon), o RLS vale e as policies decidem o que cada pessoa vê.
Pelo pg, com a senha do banco, o RLS não vale: a sua API vê tudo.
Isso não é um bug — é a divisão de responsabilidades. Quando o navegador fala direto com o banco, o banco precisa se defender sozinho (RLS). Quando quem fala com o banco é a sua API, é a API que autoriza, no middleware do Firebase da Aula 10 e nos services da Aula 13. As duas coisas convivem: mantenha as policies para o caminho do supabase-js e mantenha a autorização no back-end para o caminho SQL.
SQL
-- migrations/0003_habilitar_rls.sql-- Vale para quem acessa pelo supabase-js (chaves anon/authenticated).-- A API Express, que conecta como dono da tabela, não é afetada por estas regras.ALTERTABLEeventosENABLEROWLEVELSECURITY;CREATEPOLICY"eventos visíveis para todos"ONeventosFORSELECTUSING(true);CREATEPOLICY"somente autenticados criam eventos"ONeventosFORINSERTTOauthenticatedWITHCHECK(true);
⚠️ Atenção
A service_role key do Supabase também ignora RLS e nunca pode aparecer no front — vale a mesma regra da senha do banco. Se você precisa de acesso total, ele fica no servidor. Uma chave dessas em um repositório público é achada por robôs em minutos.
O executor de migrations da Aula 13 continua valendo; só o dialeto muda. Esta é a versão Postgres, com um ganho real: cada migration roda dentro de uma transação, então uma migration que falha no meio não deixa lixo.
JavaScript
// scripts/migrar.js — aplica migrations/*.sql em ordem, uma única vez cadaimport{readdir,readFile}from'node:fs/promises'import{readFileSync}from'node:fs'importpgfrom'pg'import{config}from'../src/config/index.js'constPASTA=newURL('../migrations/',import.meta.url)constcliente=newpg.Client({connectionString:config.DATABASE_URL,ssl:{ca:readFileSync(newURL(`../${config.DATABASE_CA}`,import.meta.url)),rejectUnauthorized:true,},})asyncfunctionmigrar(){awaitcliente.connect()awaitcliente.query(` CREATE TABLE IF NOT EXISTS migrations_executadas ( nome_arquivo TEXT PRIMARY KEY, executado_em TIMESTAMPTZ NOT NULL DEFAULT now() ) `)const{rows}=awaitcliente.query('SELECT nome_arquivo FROM migrations_executadas')constjaExecutadas=newSet(rows.map((linha)=>linha.nome_arquivo))constarquivos=(awaitreaddir(PASTA)).filter((nome)=>nome.endsWith('.sql')).sort()letaplicadas=0for(constarquivoofarquivos){if(jaExecutadas.has(arquivo)){console.log(`pulando ${arquivo} (já aplicada)`)continue}constsql=awaitreadFile(newURL(arquivo,PASTA),'utf-8')console.log(`aplicando ${arquivo}...`)// DDL transacional: ou a migration inteira entra, ou nada entra.awaitcliente.query('BEGIN')try{awaitcliente.query(sql)awaitcliente.query('INSERT INTO migrations_executadas (nome_arquivo) VALUES ($1)',[arquivo])awaitcliente.query('COMMIT')aplicadas+=1console.log(`${arquivo} aplicada`)}catch(erro){awaitcliente.query('ROLLBACK')thrownewError(`falha em ${arquivo}: ${erro.message}`)}}console.log(`concluído: ${aplicadas} migration(s) nova(s).`)}migrar().catch((erro)=>{console.error('falha ao migrar:',erro.message)process.exitCode=1}).finally(()=>cliente.end())
Três regras de convivência com migrations em um banco compartilhado:
Migration aplicada nunca é editada. O arquivo já rodou no banco de alguém. Para mudar, crie a migration seguinte (0004_corrigir_tipo_da_coluna_vagas.sql).
Uma mudança por arquivo, com nome que descreve a mudança.0005_adicionar_indice_categoria.sql conta a história no git log.
Migration que apaga dado precisa de revisão de outra pessoa.DROP COLUMN em produção não tem Ctrl+Z.
Um seed serve para que qualquer pessoa (ou o CI do Capítulo 09) tenha um banco com conteúdo em segundos. A regra de ouro é ser idempotente: rodar duas vezes não pode criar dois eventos iguais.
JavaScript
// scripts/semear.js — popula o banco com dados de exemplo, sem duplicarimport{pool}from'../src/db/pool.js'import{config}from'../src/config/index.js'constEVENTOS=[{titulo:'Semana Acadêmica de Sistemas de Informação',categoria:'palestra',local:'Auditório Central',vagas:120,descricao:'Abertura com egressos do curso contando o primeiro emprego.',},{titulo:'Minicurso de Vue 3 e Vuetify',categoria:'minicurso',local:'Laboratório 2',vagas:30,descricao:'Componentes, rotas e estado em uma tarde.',},{titulo:'Workshop de Deploy',categoria:'workshop',local:'Laboratório 1',vagas:25,descricao:'Do commit ao domínio com HTTPS.',},]asyncfunctionsemear(){if(config.NODE_ENV==='production'&&process.env.PERMITIR_SEED!=='sim'){thrownewError('recusando semear em produção sem PERMITIR_SEED=sim')}// ON CONFLICT (titulo) só funciona porque a migration 0001 criou uk_eventos_titulo.// Schema é assunto de migration; o seed apenas insere dados.letinseridos=0for(const[posicao,evento]ofEVENTOS.entries()){const{rowCount}=awaitpool.query(`INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas) VALUES ($1, $2, $3, now() + make_interval(days => $4), $5, $6) ON CONFLICT (titulo) DO NOTHING`,[evento.titulo,evento.descricao,evento.categoria,(posicao+1)*7,evento.local,evento.vagas],)inseridos+=rowCount}console.log(`seed concluído: ${inseridos} evento(s) inserido(s), ${EVENTOS.length-inseridos} já existiam.`)}semear().catch((erro)=>{console.error('falha no seed:',erro.message)process.exitCode=1}).finally(()=>pool.end())
make_interval(days => $4) cria datas relativas ao momento da execução — assim o seed nunca fica com eventos no passado, e nenhuma data literal entra no código.
Provedor faz snapshot. Ótimo. Mas o snapshot está dentro da conta do provedor: não protege contra você apagar a tabela errada e só perceber uma semana depois (a retenção do plano gratuito costuma ser curta), nem contra a conta ser suspensa. Um backup próprio, num arquivo que você guarda em outro lugar, custa cinco minutos.
# dump lógico completo (schema + dados) em formato texto, legível e versionável
pg_dump"$DATABASE_URL"--no-owner--no-privileges--file=backup-unieventos.sql
# formato comprimido, restaurado com pg_restore — melhor para bancos maiores
pg_dump"$DATABASE_URL"--no-owner--no-privileges--format=custom--file=backup-unieventos.dump
# só os dados de uma tabela (útil para levar dados de produção para o ambiente local)
pg_dump"$DATABASE_URL"--data-only--table=eventos--file=eventos.sql
--no-owner e --no-privileges removem os comandos que atribuem dono e permissões — sem eles, restaurar em outro servidor falha porque o papel do Supabase não existe lá.
Aspas duplas em "$DATABASE_URL" são obrigatórias: a URL tem ?, & e : que o shell interpretaria.
#!/usr/bin/env bash# scripts/backup.sh — dump diário com retenção de 7 diasset-euopipefail
DESTINO="/srv/backups/unieventos"CARIMBO="$(date+%Y%m%d-%H%M)"ARQUIVO="$DESTINO/unieventos-$CARIMBO.dump"
mkdir-p"$DESTINO"# DATABASE_URL vem do ambiente; nunca escreva a senha aqui dentro.
pg_dump"$DATABASE_URL"--no-owner--no-privileges--format=custom--file="$ARQUIVO"# mantém só os 7 dumps mais recentes
find"$DESTINO"-name'unieventos-*.dump'-mtime+7-delete
echo"backup gerado: $ARQUIVO ($(du-h"$ARQUIVO"|cut-f1))"
Terminal
chmod+xscripts/backup.sh
crontab-e
# uma linha, backup às 3h da manhã, com o log guardado:# 0 3 * * * DATABASE_URL='postgresql://postgres.SEU_REF:SUA_SENHA@aws-0-sa-east-1.pooler.supabase.com:5432/postgres' /srv/unieventos-api/scripts/backup.sh >> /var/log/backup-unieventos.log 2>&1
Agora a parte que quase todo mundo pula. Backup não testado não é backup. Restaure em um Postgres descartável, usando o Docker do Capítulo 07:
Terminal
# 1. sobe um Postgres vazio só para o teste
dockerrun-d--rm--namepg-teste-ePOSTGRES_PASSWORD=teste-p5433:5432postgres:17-alpine
# 2. espera aceitar conexão e restaura o dump
sleep10
pg_restore--dbname='postgresql://postgres:teste@localhost:5433/postgres'--no-ownerbackup-unieventos.dump
# 3. confere: as contagens batem com as do banco de produção?
psql'postgresql://postgres:teste@localhost:5433/postgres'-c'SELECT count(*) FROM eventos;'
psql'postgresql://postgres:teste@localhost:5433/postgres'-c'SELECT count(*) FROM inscricoes;'# 4. derruba
dockerstoppg-teste
🔎 Por baixo do capôpg_dump não é um "copiar arquivos": ele abre uma transação com um snapshot do banco e reconstrói tudo como comandos SQL — por isso o dump é consistente mesmo com o site recebendo escritas durante a cópia, e por isso ele pode ser restaurado em outra versão do Postgres. O preço: o dump é proporcional ao conteúdo, não ao disco, e demora mais em bancos grandes. Ele também exige que a versão do pg_dump seja igual ou maior que a do servidor — daí o erro aborting because of server version mismatch quando o seu Ubuntu tem pg_dump 14 e o Supabase roda uma versão bem mais nova.
🚀 Passo a passo — UniEventos com o banco no Supabase, com seed e backup¶
Ao final destes passos a unieventos-api (na sua máquina e no VPS do Capítulo 07) vai falar com um Postgres gerenciado em São Paulo, com o schema aplicado por migrations, dados de exemplo e um backup restaurado com sucesso em um banco descartável.
Está no Nível 2? Aplique o mesmo passo na cafe-cerrado-api: troque unieventos por cafe_cerrado nos nomes de banco e de tabela, e o seed de eventos pelo cardápio de produtos. A URL gerenciada, o TLS, as migrations e o teste de restauração são idênticos.
No painel do Supabase, crie o projeto unieventos na região São Paulo. Em Settings → Database, copie a string do pooler de sessão (porta 5432) e baixe o certificado.
Terminal
cdunieventos-api
mkdir-pcerts
# salve o certificado baixado como certs/banco-ca.crt
No VPS do Capítulo 07, o serviço db do compose.prod.yaml deixa de existir. Antes de removê-lo, leve os dados que já estavam lá:
Terminal
sshmeuvps
cd/srv/unieventos-api
# O compose lê o .env sozinho; o seu shell, não. Sem esta linha o -p fica# sem valor, o mysqldump abre um prompt e o arquivo sai vazio.set-a;../.env;set+a
dockercompose-fcompose.prod.yamlexec-Tdb\mysqldump-uroot-p"$DB_ROOT_PASSWORD"--single-transactionunieventos>dados-antigos.sql
Edite compose.prod.yaml: apague o serviço db, a seção volumes: e o depends_on da API. Troque as variáveis do banco por DATABASE_URL no .env de produção (com chmod 600) e suba:
curl https://api.seudominio.dev/api/eventos devolve os eventos, e o painel do Supabase mostra a conexão ativa em Settings → Database.
docker compose -f compose.prod.yaml ps no VPS mostra um serviço só (api).
npm run migrar rodado duas vezes seguidas não aplica nada na segunda.
npm run semear rodado duas vezes não duplica eventos.
O pg_restore em um contêiner vazio devolve exatamente as mesmas contagens de eventos e inscricoes.
Resultado esperado: a API roda em qualquer lugar — seu notebook, o VPS, um contêiner novo — apontando para o mesmo banco, e você tem um arquivo .dump que já provou que restaura.
A1. Explique com suas palavras a diferença entre o pooler de sessão (5432) e o de transação (6543) do Supabase. Qual dos dois a unieventos-api, que fica no ar o tempo todo, deve usar? E uma função serverless que sobe a cada requisição?
A2. A API roda em dois contêineres, cada um com max: 10 no pool. Quantas conexões o banco vê? Se o plano permite 15, o que acontece com a décima sexta e qual mensagem aparece no log?
A3. Traduza para Postgres: id INT AUTO_INCREMENT PRIMARY KEY, status ENUM('ativo','inativo'), criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP e a consulta SELECT * FROM eventos WHERE categoria = ? LIMIT ?.
A4. Um colega conectou a API pelo pg com a senha do banco, testou e disse: "as policies de RLS não estão funcionando, minha API vê tudo". Explique por que isso é esperado e onde a autorização precisa acontecer nesse caminho.
A5. Qual é a diferença prática entre ssl: { rejectUnauthorized: false } e ssl: { ca, rejectUnauthorized: true }? Descreva um ataque que o segundo impede e o primeiro não.
A6. Você rodou npm run semear três vezes. Quantos eventos existem na tabela e por quê? Que cláusula do INSERT garante isso?
B1. Meça a diferença que a região faz. Escreva um script que abre a conexão, roda SELECT 1 cem vezes em sequência e imprime o tempo total e a média por consulta. Rode contra o banco na nuvem e contra um Postgres local em Docker.
Resultado esperado: duas médias, a local na casa de 1 ms e a da nuvem em dezenas de milissegundos; um parágrafo no README.md explicando por que 100 consultas sequenciais são um problema de arquitetura.
Dica
Use performance.now() antes e depois do laço. Para o Postgres local, docker run -d --rm -e POSTGRES_PASSWORD=teste -p 5433:5432 postgres:17-alpine e uma DATABASE_URL apontando para localhost:5433 (sem TLS: um ssl: false condicional resolve).
B2. Adicione a coluna encerrado BOOLEAN NOT NULL DEFAULT false à tabela eventos usando uma migration nova, sem editar nenhuma migration existente, e faça o repositório passar a filtrar eventos encerrados na listagem.
Resultado esperado: npm run migrar aplica só o arquivo novo; GET /api/eventos deixa de trazer os encerrados; rodar npm run migrar de novo não faz nada.
Dica
O arquivo é 0004_adicionar_coluna_encerrado.sql com ALTER TABLE eventos ADD COLUMN IF NOT EXISTS encerrado BOOLEAN NOT NULL DEFAULT false;. No repositório, um AND encerrado = false no WHERE — ou um parâmetro incluirEncerrados com valor padrão.
B3. Provoque o esgotamento do pool. Baixe max para 2, crie uma rota que roda SELECT pg_sleep(3) e dispare 5 requisições simultâneas com curl em segundo plano. Observe os tempos de resposta.
Resultado esperado: as duas primeiras respondem em ~3 s, as demais em ~6 s e ~9 s — elas ficaram na fila esperando conexão. Com connectionTimeoutMillis baixo, aparece Error: timeout exceeded when trying to connect.
Dica
for i in 1 2 3 4 5; do (time curl -s localhost:3000/api/lento) & done; wait. É a demonstração prática de por que uma consulta lenta derruba o site inteiro: ela não segura só a própria requisição, segura o pool.
B4. Faça o backup completo, apague uma tabela no banco de teste (nunca no de produção) e restaure só ela a partir do dump.
Resultado esperado: pg_restore --table=eventos (ou um dump --data-only --table=eventos) devolve a tabela com a mesma contagem, sem tocar nas outras.
Dica
Faça tudo no contêiner descartável da §8: restaure o dump inteiro nele, DROP TABLE eventos CASCADE, e restaure de novo só a tabela. Repare no efeito do CASCADE sobre a chave estrangeira de inscricoes — esse é o aprendizado do exercício.
C1. Faça a API funcionar com dois bancos sem mudar uma linha dos services: DB_DIALETO=postgres usa o repositório do Supabase, DB_DIALETO=mysql usa o do MySQL do Capítulo 07. Uma variável de ambiente escolhe a implementação na inicialização, e a suíte de testes passa nos dois modos.
Dica
É o Adapter da Aula 12 aplicado ao banco: dois arquivos eventos.repository.postgres.js e eventos.repository.mysql.js com a mesma interface, e uma fábrica em src/repositories/index.js que escolhe pelo config.DB_DIALETO. O que vai doer: RETURNING * não existe no MySQL (use insertId + um SELECT), e $1 versus ?. Se cada repositório resolver isso internamente, ninguém fora deles precisa saber qual banco está por baixo.
Sua tela de eventos parece rápida no npm run dev, com o banco em Docker na mesma máquina, e fica arrastada com o banco na nuvem. Nada no código mudou — mudou a distância. Descubra exatamente quanto da lentidão é rede e quanto é consulta mal escrita, e prove a diferença com números.
Critérios de pronto
Uma tabela no README.md com o tempo médio de GET /api/eventos em três cenários: banco local, banco na nuvem na região mais próxima e banco na nuvem em outra região.
O tempo de rede (ida e volta de um SELECT 1) medido separadamente do tempo da consulta real.
Pelo menos uma consulta da API reescrita para fazer uma ida ao banco em vez de várias, com o antes e o depois medidos.
Uma frase explicando por que EXPLAIN ANALYZE mostra um tempo menor do que o que a API observa.
Pistas
Crie um segundo projeto gratuito em outra região só para a medição; apague depois.
EXPLAIN ANALYZE SELECT ... no SQL Editor dá o tempo de execução dentro do servidor. A diferença entre esse número e o que o Node cronometra é a rede.
Um laço que busca as inscrições de cada evento é o clássico problema de N+1 consultas: 1 consulta da lista + N consultas dos detalhes. JOIN ou WHERE evento_id = ANY($1) resolvem.
Meça com curl -o /dev/null -s -w "%{time_total}\n" repetido 10 vezes e tire a média — uma medição só não vale nada.
Todo mundo tem backup até precisar restaurar. Monte a rotina completa do seu projeto autoral — gerar, guardar fora do servidor, restaurar em um banco descartável e comparar — e execute-a até o fim pelo menos uma vez, com evidência.
Critérios de pronto
scripts/backup.sh gera um dump com carimbo de tempo no nome e apaga os mais antigos que 7 dias.
O script roda por cron (ou por uma tarefa agendada) e o log guarda a saída de cada execução.
Um scripts/restaurar-teste.sh sobe um banco em Docker, restaura o dump mais recente e imprime a contagem de linhas de cada tabela.
O README.md traz a saída real dos dois scripts e responde: quanto tempo você levaria para voltar ao ar se o banco fosse apagado agora?
Nenhuma senha aparece dentro dos scripts nem no crontab versionado.
Pistas
set -euo pipefail no topo faz o script parar no primeiro erro em vez de seguir e "terminar com sucesso" sem ter feito nada.
Para comparar contagens automaticamente, gere um arquivo com SELECT count(*) de cada tabela nos dois bancos e use diff.
Guardar o dump no mesmo servidor não protege contra a perda do servidor. rclone, rsync para outra máquina ou o upload para um bucket resolvem — o Capítulo 06 já ensinou o rsync.
Um dump com dados de pessoas é dado pessoal: pense onde ele fica e quem tem acesso.
⭐⭐⭐
⭐⭐⭐ Do MySQL para o Postgres sem perder um evento¶
banco-de-dadosmysqlsupabaserefatoracao
O projeto nasceu em MySQL (Aula 09 do Nível 3) e agora vai para um Postgres gerenciado. O schema muda, os tipos mudam, as consultas mudam — e os dados que já existem precisam chegar do outro lado inteiros, com as chaves estrangeiras casando. Faça a migração de verdade, com verificação automática de que nada se perdeu.
Critérios de pronto
Um script (scripts/migrar-mysql-para-postgres.js) lê do MySQL e grava no Postgres, em ordem de dependência, dentro de uma transação por tabela.
Os ids são preservados, e as sequências de identidade do Postgres são ajustadas para não colidir com os ids existentes.
Um relatório final compara, tabela a tabela, a contagem de origem e de destino, e falha se qualquer uma divergir.
Datas chegam com o mesmo instante nos dois bancos (atenção ao fuso do DATETIME sem fuso do MySQL).
A API sobe apontando para o Postgres e todos os testes da Aula 13 passam sem mudar um único service.
Pistas
Ordem importa: eventos antes de inscricoes, senão a chave estrangeira reclama. Ou desabilite as restrições durante a carga e reabilite no fim.
Para preservar ids em uma coluna GENERATED ALWAYS AS IDENTITY, o INSERT precisa de OVERRIDING SYSTEM VALUE. Depois, SELECT setval(pg_get_serial_sequence('eventos','id'), max(id)) FROM eventos acerta o contador.
Inserir 5.000 linhas com 5.000 INSERT custa 5.000 idas e voltas de rede. Procure inserção em lote (UNNEST ou INSERT ... VALUES com muitas tuplas) e compare o tempo.
DATETIME do MySQL não tem fuso; TIMESTAMPTZ tem. Decida explicitamente em qual fuso os valores antigos foram gravados antes de converter — e escreva essa decisão em um comentário.
Crie o banco em um serviço gerenciado (Supabase, Neon ou MySQL na nuvem), na região mais próxima, e conecte a API por pool com TLS verificado.
Substitua as variáveis soltas do banco por DATABASE_URL (mais DATABASE_CA) no src/config/index.js e atualize o .env.example.
Garanta que npm run migrar cria o schema do zero em um banco vazio e que npm run semear popula sem duplicar quando rodado duas vezes.
Gere um backup, restaure-o em um banco descartável em Docker e registre no README.md a seção Banco de dados: qual serviço, qual região, como migrar, como semear, como fazer e como restaurar o backup — em no máximo 15 linhas.
Critério de pronto: um colega consegue, só com o README.md e um .env que você entregue por outro canal, subir a API contra o banco na nuvem e ver dados na rota de listagem. Nenhuma senha aparece no repositório (git log -p incluído).
No próximo capítulo o trabalho manual acaba: o GitHub Actions passa a rodar lint e testes a cada push, publicar o site estático sozinho, construir a imagem Docker da API e enviá-la ao VPS por SSH — e você vai proteger a branch principal para que só entre código que passou por tudo isso.
Explicar o que são integração contínua e entrega contínua, e que problema real cada uma resolve em um time (mesmo um time de duas pessoas).
Ler e escrever um workflow do GitHub Actions em YAML, distinguindo workflow, evento, job, step, runner e action.
Montar um workflow de CI que instala com npm ci, roda lint e testes a cada push e pull request, com cache de dependências e um banco de verdade em contêiner de serviço.
Guardar credenciais em secrets e variáveis do repositório, e usá-las sem vazá-las nos logs.
Publicar automaticamente um site estático no GitHub Pages (ou no Netlify) a partir do main.
Construir e enviar a imagem Docker para o GHCR e atualizar o VPS por SSH, com tag rastreável e rollback em um comando.
Proteger a branch principal exigindo que a esteira passe, e exibir o badge do resultado no README.md.
[ ] unieventos-api com npm test funcionando localmente (Aula 13 do Nível 3: vitest + supertest) e Dockerfile do Capítulo 07.
[ ] Banco na nuvem configurado por DATABASE_URL (Capítulo 08) — é o que torna o deploy automático seguro.
[ ] Repositório no GitHub com a branch main (Capítulo 02) e a imagem já publicada uma vez no GHCR na mão (Capítulo 07).
[ ] VPS acessível por SSH com o usuário deploy, docker compose instalado e compose.prod.yaml funcionando (Capítulos 06 e 07).
[ ] unieventos-web (ou o site-evento do Nível 1) gerando dist/ com npm run build.
No Capítulo 07 você construiu a imagem na sua máquina e a subiu no GHCR digitando os comandos; no Capítulo 08 o banco saiu do servidor e a unieventos-api ficou sem estado — uma imagem Docker que pode ser destruída e recriada sem perder nada. Só que o caminho até o ar ainda é manual: você constrói a imagem no notebook, faz docker push, abre o SSH, roda docker compose pull e torce. São seis comandos em três máquinas, na ordem certa, sempre que muda uma linha — e alguém vai esquecer um deles em uma sexta-feira à noite. Hoje esse trabalho passa a ser feito por um robô a cada git push: instalar dependências, rodar lint e testes, publicar o site estático, construir a imagem, enviar para o GHCR e atualizar o VPS por SSH — e a branch main ganha um porteiro, que recusa o código que não passar pela esteira. No Capítulo 10 essa mesma esteira ganha medidas de qualidade — cobertura, Lighthouse e monitoramento de erros em produção.
CI — integração contínua. Cada mudança é integrada ao código principal várias vezes por dia, e uma máquina neutra verifica se ela continua funcionando. A palavra importante é "neutra": o teste que roda no seu computador prova que funciona no seu computador, com as suas variáveis de ambiente, as suas dependências instaladas há três meses e aquele arquivo que você esqueceu de commitar. O runner do GitHub começa do zero, clona o repositório e só tem o que está versionado. É por isso que a CI pega o clássico "esqueci de commitar o package-lock.json".
CD — entrega/implantação contínua. O que passou na CI vai para produção sem intervenção manual. O ganho não é preguiça: é que o deploy deixa de ser um evento raro e assustador (e por isso arriscado) e vira uma rotina de dois minutos que acontece dez vezes por semana. Quanto menor a mudança que vai ao ar, mais fácil descobrir qual delas quebrou.
A esteira que você vai montar:
Texto
git push
│
├─► CI ......... npm ci → lint → testes (todo push e todo PR)
│ │
│ └─ falhou? o PR fica vermelho e não pode ser mesclado
│
└─► main ─┬─► site estático: build → GitHub Pages
│
└─► API: build da imagem → GHCR → ssh no VPS → compose pull/up → /health
Três princípios que valem mais que qualquer YAML:
A esteira é a fonte da verdade. "Na minha máquina passa" não conta. Se o teste falha na CI, o código está errado — ou o teste depende de algo que não está versionado.
Rápido ou ninguém usa. Uma CI de 15 minutos é ignorada; as pessoas mesclam sem esperar. Cache, jobs paralelos e testes que não dependem de rede mantêm o ciclo abaixo de 3 minutos.
Falha barulhenta, sucesso silencioso. O sucesso é o esperado. O que precisa gritar é a falha — no PR, no badge e, se você quiser, no e-mail.
🧠 Você sabia?
A ideia de integrar o trabalho de todo mundo continuamente é bem mais velha que a nuvem: nos anos 1990 o daily build da Microsoft era uma regra quase religiosa, e quem quebrasse a compilação do dia herdava um chapéu ridículo ou o dever de vigiar o próximo build. O ritual era feio, mas o objetivo é exatamente o mesmo de um workflow de CI: tornar visível, no mesmo dia, quem quebrou o quê — em vez de descobrir na integração final, quando já é impossível saber qual das duzentas mudanças foi a culpada.
🔬 Investigue
Abra um projeto popular no GitHub (por exemplo vuejs/core ou expressjs/express), entre na aba Actions e escolha uma execução recente. Responda: quantos jobs rodam em paralelo? Quanto tempo levou o mais lento? Em qual sistema operacional cada um roda? Depois clique em um step e veja o log linha a linha. Você está lendo exatamente o mesmo tipo de arquivo YAML que vai escrever nos próximos 40 minutos — a diferença é a quantidade de jobs, não a complexidade de cada um.
Um workflow é um arquivo YAML dentro de .github/workflows/ no seu repositório. O GitHub lê a pasta a cada push e obedece ao que estiver lá. O vocabulário:
Termo
O que é
workflow
um arquivo .yml; tem um nome, uma lista de eventos e um ou mais jobs
evento (on:)
o que dispara: push, pull_request, schedule, workflow_dispatch (botão manual)
job
um conjunto de steps que roda em uma máquina; jobs rodam em paralelo por padrão
runner
a máquina temporária (ubuntu-latest) criada para o job e destruída no fim
step
um passo: ou um run: (comando de shell) ou um uses: (uma action pronta)
action
um pedaço reutilizável, referenciado por dono/nome@versão
secret
valor cifrado do repositório, disponível como ${{ secrets.NOME }}
O menor workflow útil que existe:
YAML
# .github/workflows/exemplo.ymlname:Exemploon:[push]jobs:dizer-oi:runs-on:ubuntu-lateststeps:-name:Falar com o mundorun:echo "rodando no commit ${{ github.sha }}"
Cinco regras de YAML que resolvem 90% dos erros de sintaxe:
Indentação é com espaços, nunca com tabulação. Dois espaços por nível é a convenção.
Lista é - no começo da linha, no mesmo nível dos irmãos.
chave: valor precisa do espaço depois dos dois-pontos.
Valor com : dentro precisa de aspas: run: "echo a: b".
Bloco de várias linhas usa | (preserva as quebras) — é como se escrevem scripts inteiros dentro de um run:.
💡 Dica
O VS Code entende workflows do GitHub Actions se você instalar a extensão oficial GitHub Actions: ela valida o YAML enquanto você digita, completa nomes de actions e avisa quando uma chave não existe. Vale mais do que descobrir o erro dois minutos depois, no log da execução.
Cada job começa em uma máquina virtual limpa, com Ubuntu, Git, Node, Docker e mais uma centena de ferramentas pré-instaladas. Duas consequências que confundem todo mundo no começo:
Jobs não compartilham disco. O que o job A baixou não existe no job B. Para passar arquivos entre jobs, use artefatos (actions/upload-artifact) ou reconstrua.
Cada step compartilha o disco, mas não o shell. Um cd pasta em um step não vale no próximo (use working-directory:), e uma variável exportada com export some (escreva em $GITHUB_ENV).
Este é o workflow que você vai usar todos os dias. Ele roda a cada push no main e a cada pull request:
YAML
# unieventos-api/.github/workflows/ci.ymlname:CIon:push:branches:[main]pull_request:branches:[main]# Princípio do menor privilégio: este workflow só precisa LER o repositório.permissions:contents:read# Se você empurrar dois commits seguidos, cancela a execução antiga e roda só a nova.concurrency:group:ci-${{ github.ref }}cancel-in-progress:truejobs:verificar:name:Lint e testesruns-on:ubuntu-latesttimeout-minutes:10# O config.js valida o ambiente com zod (Aula 13): sem estas variáveis o processo# nem sobe. No runner não existe .env, então elas entram aqui. Os testes usam# repositórios falsos, então a DATABASE_URL só precisa ter forma válida.env:NODE_ENV:testDATABASE_URL:postgresql://postgres:teste@localhost:5432/unieventos_testeFIREBASE_PROJECT_ID:projeto-de-testeCORS_ORIGEM_PERMITIDA:http://localhost:5173steps:-name:Baixar o códigouses:actions/checkout@v4-name:Preparar o Node 22 com cache do npmuses:actions/setup-node@v4with:node-version:'22'cache:npm-name:Instalar dependências do lockfilerun:npm ci-name:Conferir o estilo do códigorun:npm run lint --if-present-name:Rodar os testesrun:npm test
Passo a passo do que acontece:
actions/checkout@v4 clona o repositório dentro do runner. Sem ele, a máquina está vazia — é o step que todo workflow começa.
actions/setup-node@v4 com cache: npm instala o Node 22 e restaura o cache do npm a partir do package-lock.json. Na primeira execução ele guarda; nas seguintes, o npm ci cai de ~40 s para ~8 s.
npm ci (e não npm install) instala exatamente as versões travadas no lockfile e falha se o package.json e o package-lock.json estiverem dessincronizados. Em CI é sempre ci.
--if-present faz o npm ignorar um script que ainda não existe. Assim o workflow já funciona antes de você montar o ESLint (Capítulo 10).
timeout-minutes evita que um teste travado consuma minutos de execução até o limite padrão de seis horas.
⚠️ Atençãonpm ci apaga a pasta node_modules e reinstala do zero. Se o seu package-lock.json não estiver commitado, o step falha com npm ci can only install packages when your package.json and package-lock.json … are in sync. Rode npm install localmente, commite o lockfile e empurre de novo — é o erro número um de quem monta a primeira CI.
Os testes da Aula 13 usam repositórios falsos e não precisam de banco. Mas em algum momento você vai querer testar a consulta SQL de verdade. O GitHub sobe contêineres auxiliares para o job com a chave services: — é o docker compose do Capítulo 07, embutido na CI:
YAML
# unieventos-api/.github/workflows/ci.yml — segundo job, no mesmo arquivointegracao:name:Testes de integração com Postgresruns-on:ubuntu-latesttimeout-minutes:15services:postgres:image:postgres:17-alpineenv:POSTGRES_USER:postgresPOSTGRES_PASSWORD:testePOSTGRES_DB:unieventos_testeports:-5432:5432# Sem healthcheck o job começa antes de o banco aceitar conexão.options:>---health-cmd "pg_isready -U postgres"--health-interval 10s--health-timeout 5s--health-retries 5env:NODE_ENV:testDATABASE_URL:postgresql://postgres:teste@localhost:5432/unieventos_testeFIREBASE_PROJECT_ID:projeto-de-testeCORS_ORIGEM_PERMITIDA:http://localhost:5173steps:-uses:actions/checkout@v4-uses:actions/setup-node@v4with:node-version:'22'cache:npm-run:npm ci-name:Criar o schema no banco do jobrun:npm run migrar-name:Rodar a suíte inteirarun:npm test
O contêiner de serviço fica acessível em localhost:5432 porque a porta foi mapeada. Falta um detalhe: o pool do Capítulo 08 exige TLS com certificado, e esse Postgres local não tem TLS nenhum. Torne a decisão explícita no código, em vez de manter dois arquivos:
JavaScript
// src/db/pool.js — trecho: TLS só quando o banco é remotoconstehBancoLocal=/@(localhost|127\.0\.0\.1)[:/]/.test(config.DATABASE_URL)exportconstpool=newpg.Pool({connectionString:config.DATABASE_URL,ssl:ehBancoLocal?false:{ca:certificadoDaAutoridade,rejectUnauthorized:true},max:10,})
🔎 Por baixo do capô
Contêiner de serviço não é a mesma coisa que docker compose: o GitHub cria uma rede própria para o job e sobe cada serviço nela antes do primeiro step. Se os steps rodassem dentro de um contêiner (chave container:), o endereço do banco seria o nome do serviço (postgres:5432), como no compose; como os nossos steps rodam direto no runner, o endereço é localhost na porta que você mapeou. Trocar um pelo outro é a causa de metade dos ECONNREFUSED em CI.
Se o seu projeto precisa funcionar em mais de uma versão do Node, a matriz cria um job por combinação, todos em paralelo:
YAML
compatibilidade:runs-on:ubuntu-lateststrategy:fail-fast:false# não cancela as outras quando uma falhamatrix:node:['22','24']name:Testes no Node ${{ matrix.node }}steps:-uses:actions/checkout@v4-uses:actions/setup-node@v4with:node-version:${{ matrix.node }}cache:npm-run:npm ci-run:npm testenv:NODE_ENV:testDATABASE_URL:postgresql://postgres:teste@localhost:5432/unieventos_testeFIREBASE_PROJECT_ID:projeto-de-testeCORS_ORIGEM_PERMITIDA:http://localhost:5173
A chave SSH do VPS, o token do Netlify e a senha do banco não podem entrar no repositório. O GitHub guarda esses valores cifrados em Settings → Secrets and variables → Actions:
Secrets — cifrados, nunca exibidos de novo depois de salvos, e mascarados nos logs (aparecem como ***). Use para senhas, tokens e chaves.
Variables — texto simples, visível no painel e no log. Use para coisas não sensíveis, como a URL pública da API.
YAML
-name:Usar um segredo sem vazá-lorun:|# CERTO: o valor vai para a variável de ambiente do processocurl -fsS -H "Authorization: Bearer $TOKEN" https://api.exemplo.com/statusenv:TOKEN:${{ secrets.TOKEN_DA_API }}
Três regras sobre segredos:
Nunca imprima um segredo.echo "${{ secrets.X }}" é mascarado pelo GitHub, mas qualquer transformação (base64, uma quebra em pedaços) escapa da máscara e vira log público para sempre.
Um segredo por finalidade. Uma chave SSH só para o deploy, revogável sem afetar o resto.
Segredos não existem em PRs de forks. Quem abre um pull request a partir de um fork não recebe os seus segredos — por segurança óbvia. Por isso o job de deploy roda só em push no main, nunca em pull_request.
Além dos seus, o GitHub injeta em todo workflow um segredo automático, o GITHUB_TOKEN: um token temporário, válido só durante a execução, cujas permissões você declara no bloco permissions:. É com ele que o workflow publica no GHCR (§6) sem você criar token nenhum.
⚠️ Atençãopermissions: sem declaração herda o padrão da organização, que pode ser de escrita em tudo. Declare sempre o mínimo: contents: read para CI, mais packages: write para publicar imagem, mais pages: write e id-token: write para o Pages. Um workflow comprometido com permissão de escrita pode reescrever o seu repositório.
No Capítulo 03 você publicou o site-evento no GitHub Pages arrastando arquivos e escolhendo uma branch. Agora o Pages passa a ser alimentado pelo próprio workflow — o que resolve o caso de um site que precisa ser construído antes (Vite, do Nível 3).
YAML
# unieventos-web/.github/workflows/publicar-site.ymlname:Publicar siteon:push:branches:[main]workflow_dispatch:# botão "Run workflow" na aba Actionspermissions:contents:readpages:write# publicar no Pagesid-token:write# provar ao Pages que a publicação veio deste workflow# Um deploy por vez, sem cancelar o que já começou a publicar.concurrency:group:pagescancel-in-progress:falsejobs:construir:name:Construir o siteruns-on:ubuntu-lateststeps:-uses:actions/checkout@v4-name:Preparar o Pagesid:pagesuses:actions/configure-pages@v5-uses:actions/setup-node@v4with:node-version:'22'cache:npm-run:npm ci# base_path resolve o subcaminho /nome-do-repositorio/ do Pages (Capítulo 03).-name:Gerar o dist/run:npm run build -- --base="${{ steps.pages.outputs.base_path }}"env:VITE_API_URL:${{ vars.VITE_API_URL }}-name:Empacotar o dist/ como artefato do Pagesuses:actions/upload-pages-artifact@v3with:path:distpublicar:name:Publicar no Pagesneeds:construir# só roda se o build terminou bemruns-on:ubuntu-latestenvironment:name:github-pagesurl:${{ steps.publicacao.outputs.page_url }}steps:-name:Publicarid:publicacaouses:actions/deploy-pages@v4
Antes do primeiro push, ligue a chave: Settings → Pages → Build and deployment → Source: GitHub Actions. Sem isso o job falha com Get Pages site failed.
Repare em duas coisas:
vars.VITE_API_URL é uma variável (não segredo): a URL pública da API vai ser embutida no JavaScript e ficaria visível de qualquer jeito. Como você viu no Capítulo 07, tudo que começa com VITE_ é resolvido em tempo de build — mudar a variável exige rodar o workflow de novo.
environment: cria um ambiente nomeado no GitHub, com histórico de implantações e a URL clicável no fim da execução. É também onde se configura aprovação manual (§8).
O token sai de User settings → Applications → Personal access tokens no Netlify, e o NETLIFY_SITE_ID do painel do site (Site configuration → General). Em produção, fixe a versão principal da CLI (netlify-cli@<versão>) para não ser surpreendido por uma mudança de comportamento.
Este é o workflow que fecha a esteira: constrói a imagem do Capítulo 07, publica no GHCR e manda o VPS baixar a nova versão.
YAML
# unieventos-api/.github/workflows/deploy.ymlname:Publicar imagem e implantaron:push:branches:[main]workflow_dispatch:permissions:contents:readpackages:write# publicar no GitHub Container Registryconcurrency:group:deploy-producaocancel-in-progress:false# nunca cancele um deploy pela metadejobs:imagem:name:Construir e publicar a imagemruns-on:ubuntu-latesttimeout-minutes:20steps:-uses:actions/checkout@v4-name:Preparar o Buildxuses:docker/setup-buildx-action@v3-name:Autenticar no GHCRuses:docker/login-action@v3with:registry:ghcr.iousername:${{ github.actor }}password:${{ secrets.GITHUB_TOKEN }}-name:Calcular nome e tags da imagemid:metadadosuses:docker/metadata-action@v5with:images:ghcr.io/${{ github.repository }}# prefix= (vazio) remove o prefixo padrão "sha-": a tag fica sendo# exatamente o SHA do commit, o mesmo valor usado no deploy abaixo.tags:|type=sha,format=long,prefix=type=raw,value=latest-name:Construir e enviaruses:docker/build-push-action@v6with:context:.platforms:linux/amd64push:truetags:${{ steps.metadados.outputs.tags }}labels:${{ steps.metadados.outputs.labels }}cache-from:type=ghacache-to:type=gha,mode=maximplantar:name:Implantar no VPSneeds:imagemruns-on:ubuntu-latesttimeout-minutes:10environment:producaosteps:-name:Atualizar os contêineres por SSHuses:appleboy/ssh-action@v1with:host:${{ secrets.VPS_HOST }}username:${{ secrets.VPS_USUARIO }}key:${{ secrets.VPS_CHAVE_SSH }}script:|set -ecd /srv/unieventos-apiexport TAG_IMAGEM="${{ github.sha }}"docker compose -f compose.prod.yaml pulldocker compose -f compose.prod.yaml up -ddocker image prune -ffor tentativa in 1 2 3 4 5 6 7 8 9 10; doif curl -fsS http://127.0.0.1:3000/health > /dev/null; thenecho "no ar na versão $TAG_IMAGEM"exit 0fisleep 3doneecho "a API não respondeu ao /health em 30 s"exit 1
docker/metadata-action@v5 calcula as tags da imagem a partir do evento. Com a configuração acima, cada push no main publica duas tags para a mesma imagem: latest e o SHA completo do commit (ghcr.io/usuario/unieventos-api:9f2c1a…). O prefix= vazio importa: sem ele, type=sha gera sha-9f2c1a…, e o script de deploy — que usa ${{ github.sha }} puro — pediria uma tag que não existe, falhando com manifest unknown.
Para o compose.prod.yaml usar essa tag, troque a versão fixa por uma variável:
${TAG_IMAGEM:-latest} significa "use a variável TAG_IMAGEM; se ela não existir, use latest". Como o script do deploy exporta TAG_IMAGEM com o SHA do commit, o servidor sobe exatamente a imagem daquele commit — e o rollback vira um comando só, com o SHA do commit anterior:
💡 Dica
Publicar só latest parece mais simples e é uma armadilha: latest não identifica nada, dois deploys diferentes têm a mesma tag e não existe para onde voltar. Tag por commit é o que transforma "deu ruim, e agora?" em trinta segundos de rollback.
Não use a sua chave pessoal. Gere um par dedicado, sem senha (o robô não tem como digitá-la), e autorize só ele:
Terminal
# na SUA máquina
ssh-keygen-ted25519-C"github actions unieventos"-fchave-deploy-N""# envia a chave PÚBLICA para o VPS
ssh-copy-id-ichave-deploy.pubmeuvps
# mostra a chave PRIVADA para copiar (inteira, com as linhas BEGIN e END)
catchave-deploy
No GitHub, crie três secrets: VPS_HOST (o IP ou domínio), VPS_USUARIO (deploy) e VPS_CHAVE_SSH (o conteúdo completo de chave-deploy, incluindo as linhas -----BEGIN OPENSSH PRIVATE KEY----- e -----END OPENSSH PRIVATE KEY-----). Depois apague o arquivo privado da sua máquina: shred -u chave-deploy.
⚠️ Atenção
Quem tem essa chave entra no seu servidor. Limite o estrago antes de precisar: no ~/.ssh/authorized_keys do VPS, prefixe a linha da chave com from="140.82.0.0/16",no-agent-forwarding,no-port-forwarding para restringir a origem, ou crie um usuário robo que só pode rodar os comandos do deploy. E, se algum dia o repositório for exposto, revogue a chave apagando a linha do authorized_keys — é mais rápido do que trocar tudo.
Proteção da branch — em Settings → Branches → Add branch protection rule (ou em Rules → Rulesets), para main:
[ ] Exigir pull request antes de mesclar (com pelo menos uma aprovação, se você trabalha em dupla).
[ ] Exigir que verificações de status passem: marque o job Lint e testes.
[ ] Exigir que a branch esteja atualizada com o main antes de mesclar.
[ ] Bloquear force push e exclusão da branch.
A partir daí, o botão "Merge pull request" fica cinza enquanto a CI estiver vermelha. É a regra do Capítulo 02 (trabalhar por pull request) ganhando um porteiro que não esquece e não faz exceção para ninguém — nem para você, se marcar "Include administrators".
🧠 Você sabia?
O GitHub Actions é gratuito e ilimitado para repositórios públicos. Para repositórios privados existe uma cota mensal de minutos, e o consumo depende do sistema: um minuto de runner Linux conta como um minuto; Windows conta como dois; macOS conta como dez. É por isso que praticamente toda CI de projeto JavaScript roda em ubuntu-latest — e mais um motivo para deixar o repositório do seu projeto autoral público, com o .env de fora, claro.
🚀 Passo a passo — do git push ao ar, sem tocar no servidor¶
Ao final destes passos, empurrar um commit no main da unieventos-api vai rodar os testes, construir a imagem, publicá-la no GHCR e atualizar o VPS sozinho — e um pull request que quebra os testes não vai conseguir ser mesclado.
Está no Nível 2? Aplique o mesmo passo na cafe-cerrado-api: troque o nome do repositório e o da imagem no GHCR, e o resto dos workflows — ci.yml, imagem.yml e deploy.yml — vale linha por linha.
Crie .github/workflows/ci.yml com o conteúdo da §3 (só o job verificar, por enquanto). Commite em uma branch, não no main:
Terminal
gitswitch-cci-inicial
gitadd.github/workflows/ci.yml
gitcommit-m"ci: rodar lint e testes a cada push e pull request"
gitpush-uoriginci-inicial
ghprcreate--fill
Abra o pull request no navegador: em segundos aparece a verificação "CI / Lint e testes" rodando. Espere ficar verde.
Com a CI verde no main, aplique a proteção de branch da §7 exigindo o status Lint e testes. Teste: crie uma branch com um teste quebrado, abra o PR e confirme que o botão de merge fica bloqueado.
Crie .github/workflows/deploy.yml com o conteúdo da §6, abra o PR, espere a CI e mescle. Assim que o merge entra no main, abra a aba Actions: o job imagem leva uns 2 minutos (na primeira vez; depois o cache derruba para menos de 1) e o implantar termina em segundos.
No unieventos-web, crie .github/workflows/publicar-site.yml (§5), ligue Settings → Pages → Source: GitHub Actions e cadastre a variável VITE_API_URL com a URL pública da API. Empurre e acompanhe.
Altere uma mensagem visível da API (por exemplo, o texto de erro 404), commite em uma branch, abra o PR, espere o verde, mescle — e não faça mais nada. Em três minutos, curl https://api.seudominio.dev/rota-que-nao-existe mostra o texto novo.
A1. Explique a diferença entre on: push e on: pull_request e diga por que o workflow de deploy usa só o primeiro. O que aconteceria se ele também rodasse em pull requests vindos de forks?
A2. Preveja a saída. Um workflow tem dois jobs sem needs: entre eles. Eles rodam em sequência ou em paralelo? O job B enxerga um arquivo que o job A criou com touch relatorio.txt? Justifique.
A3. Por que npm ci e não npm install na CI? Cite dois comportamentos diferentes entre os dois comandos.
A4. Este step está errado: - run: cd unieventos-api seguido de - run: npm test. Explique por que o segundo step não roda dentro da pasta e escreva a correção.
A5. Você precisa guardar a URL pública da API (https://api.seudominio.dev) e o token do Netlify. Qual dos dois vai em secret e qual em variable? Justifique com uma frase.
A6. O que o bloco permissions: contents: read, packages: write autoriza e o que ele impede? Por que declarar isso é melhor do que deixar o padrão?
B1. Adicione ao ci.yml um step que roda npm audit --audit-level=high e faz o job falhar se houver vulnerabilidade alta ou crítica nas dependências de produção.
Resultado esperado: um PR com uma dependência vulnerável fica vermelho; o log mostra o pacote, a severidade e a versão corrigida.
Dica
npm audit --omit=dev --audit-level=high limita a checagem ao que vai para produção. Se hoje o seu projeto já tem um aviso conhecido e sem correção, continue-on-error: true no step deixa o aviso visível sem bloquear a esteira — mas isso é dívida, anote no README.
B2. Faça a CI só rodar quando o código muda. Use paths-ignore para ignorar alterações em README.md, docs/** e .gitignore, e prove que funciona com dois commits.
Resultado esperado: um commit que muda só o README.md não dispara execução nenhuma na aba Actions; um commit em src/ dispara.
Dica
paths-ignore fica dentro do push: e do pull_request:, no mesmo nível de branches:. Cuidado: se a proteção de branch exige o status "Lint e testes" e o PR não dispara a CI, o merge trava esperando um status que nunca virá — teste esse cenário e descreva a solução.
B3. Publique um relatório como artefato. Faça o job de testes gerar a saída em arquivo e anexá-la à execução com actions/upload-artifact@v4, com retenção de 7 dias.
Resultado esperado: na página da execução, uma seção Artifacts com um arquivo baixável contendo a saída dos testes.
Dica
npm test -- --reporter=junit --outputFile=relatorio.xml (Vitest) gera o arquivo. Use if: always() no step de upload, senão ele não roda justamente quando você mais precisa: quando o teste falhou.
B4. Meça o ganho do cache. Rode o workflow uma vez com cache: npm no setup-node e outra sem, e compare a duração do step npm ci nos logs.
Resultado esperado: uma tabela no README.md com os dois tempos e o percentual de redução.
Dica
O cache só existe a partir da segunda execução da mesma chave (que vem do package-lock.json). Compare a terceira execução com cache contra uma execução em que você removeu a linha cache: npm — e repare que o step passa a se chamar "Post Setup Node" quando o cache é salvo.
C1. Faça o deploy acontecer apenas quando você criar uma tag de versão (v1.2.0), e não a cada push no main. A imagem precisa ser publicada com a tag da versão (1.2.0), com 1.2 e com latest, e o VPS precisa subir exatamente a versão criada.
Dica
No on:, troque branches: [main] por tags: ['v*']. No docker/metadata-action@v5, type=semver,pattern={{version}} e type=semver,pattern={{major}}.{{minor}} geram as tags a partir do nome da tag do Git. No script SSH, ${{ github.ref_name }} traz v1.2.0 — decida se a imagem usa o v ou não e mantenha a decisão nos dois lados.
Uma CI que nunca ficou vermelha não provou nada — talvez ela não esteja rodando o que você pensa. Comprove que a sua esteira reprova de verdade, nas três formas de errar que mais acontecem, e documente o que cada uma mostra no log.
Critérios de pronto
Três pull requests, cada um com uma falha diferente e proposital: um teste quebrado, um erro de lint e um package-lock.json dessincronizado do package.json.
Os três aparecem vermelhos e com o merge bloqueado pela proteção de branch.
Uma tabela no README.md com a mensagem de erro literal de cada caso e a correção.
Os três PRs são fechados sem mesclar, e o main continua verde.
Pistas
Para dessincronizar o lockfile, edite a versão de uma dependência no package.json sem rodar npm install.
Se o erro de lint não reprova, veja se o step do lint está mesmo rodando: npm run lint --if-present não falha quando o script não existe.
A mensagem literal está no log do step que falhou; a aba Annotations da execução traz o resumo.
Ao fechar um PR sem mesclar, apague também a branch remota — gh pr close --delete-branch.
Revisar um PR lendo o diff é uma coisa; abrir o site do PR no navegador é outra. Faça com que todo pull request do unieventos-web publique uma versão temporária e comente no próprio PR o endereço onde ela pode ser vista.
Critérios de pronto
Um workflow disparado por pull_request constrói o site e publica uma pré-visualização (deploy preview do Netlify, do Cloudflare Pages ou uma pasta por PR no seu VPS).
Um comentário automático no PR traz o link, atualizado a cada novo commit em vez de repetido dez vezes.
A pré-visualização é apagada quando o PR é fechado ou mesclado.
O workflow não expõe segredo nenhum em PR vindo de fork — descreva no README como você tratou esse caso.
Pistas
netlify deploy sem --prod já devolve uma URL de pré-visualização; a saída do comando traz o endereço, que você pode capturar para $GITHUB_OUTPUT.
Para comentar, gh pr comment ${{ github.event.pull_request.number }} --body "..." usando o GITHUB_TOKEN com pull-requests: write.
Para não repetir comentários, procure por um marcador oculto no corpo do comentário existente e edite-o em vez de criar outro.
O evento pull_request com types: [closed] é o gancho para a limpeza.
Testes com repositório falso não pegam erro de SQL. Faça a CI validar as consultas contra um Postgres real, aplicando as migrations do zero a cada execução — e, de quebra, provar que toda migration nova também desfaz o que fez.
Critérios de pronto
Um job de integração sobe o banco (contêiner de serviço ou uma branch efêmera do Neon), roda npm run migrar em um banco vazio e executa a suíte contra ele.
Pelo menos três testes exercitam SQL de verdade: listagem com filtro, inserção com restrição única violada e exclusão em cascata.
Cada migration tem um par .desfazer.sql, e o job aplica todas, desfaz todas e aplica de novo — terminando com o schema idêntico.
O job roda em menos de 3 minutos e não depende do banco de produção em nenhum momento.
Pistas
O contêiner de serviço da §3.1 já dá o banco; o --health-cmd é o que evita a corrida entre o job e o Postgres.
Para comparar schemas antes e depois, pg_dump --schema-only nos dois momentos e diff entre os arquivos.
Se optar pelo Neon, uma branch do banco nasce em segundos com uma cópia dos dados; crie-a no início do job e apague no fim, mesmo quando a suíte falha (if: always()).
Teste em cascata é onde aparecem os erros interessantes: apague um evento com inscrições e confira o que sobrou.
🔥
🔥 Boss — A esteira completa, com rollback automático¶
ci-cddockerdeploynginxseguranca
Você tem CI, imagem publicada e deploy por SSH. Falta o que separa um pipeline de aula de um pipeline de produção: saber que o deploy deu errado e desfazê-lo sozinho. Monte a esteira completa do UniEventos (front e back), em que um push no main entrega tudo no ar — e uma versão quebrada volta atrás sem ninguém acordar.
Critérios de pronto
Um push no main da API dispara, em ordem: testes → build da imagem com tag por commit → publicação no GHCR → deploy no VPS → verificação de saúde.
A verificação de saúde consulta https://api.seudominio.dev/healthde fora do servidor (não só 127.0.0.1) por até 60 segundos.
Se a verificação falhar, o próprio workflow refaz o deploy da versão anterior e termina em vermelho, com a versão anterior no ar e funcionando.
Um push no main do front publica o site e invalida o cache, e o site publicado consome a API publicada (sem erro de CORS e sem conteúdo misto, Capítulo 04).
O main é protegido: PR obrigatório, CI verde obrigatória, sem force push.
O README.md traz um diagrama em texto da esteira, a lista de secrets necessários (nomes, nunca valores) e o procedimento de rollback manual em 3 linhas.
Nenhum segredo aparece em log algum; o workflow de deploy não roda em PR de fork.
Pistas
A versão anterior está a um comando de distância: guarde o SHA que estava no ar antes (docker inspect no contêiner atual, ou a saída de git rev-parse HEAD~1) em uma variável antes de trocar a imagem.
if: failure() em um step faz dele um step de compensação, que só roda quando algo antes falhou. É o gancho natural do rollback.
Para a verificação de fora, um step com curl --fail --retry 10 --retry-delay 6 --retry-all-errors no próprio runner testa o caminho inteiro: DNS, nginx, HTTPS e API.
Deploy com zero interrupção é outro nível: suba o contêiner novo em outra porta, confira a saúde dele, troque o proxy_pass do nginx e só então derrube o antigo. Se for tentar, faça o nginx -t antes de cada reload.
O front e o back estão em repositórios diferentes: workflow_run ou repository_dispatch permitem que um dispare o outro, se você quiser encadeá-los.
Crie .github/workflows/ci.yml rodando npm ci, lint e testes a cada push e pull request, com cache: npm, permissions: contents: read, concurrency e timeout-minutes.
Proteja o main: pull request obrigatório e a verificação da CI como status obrigatório.
Adicione o badge da CI na primeira linha do README.md.
Crie o segundo workflow: publicação automática do front (Pages ou Netlify) ou da imagem no GHCR — o que fizer sentido para o seu projeto.
Abra um PR com um erro proposital, mostre que ele foi bloqueado, corrija no mesmo PR e mescle.
Critério de pronto: a aba Actions tem pelo menos quatro execuções (duas vermelhas e duas verdes), o badge está verde, o main não aceita push direto e nenhum segredo aparece em log ou no código.
Guarde no seu repositório: commit + push, junto com o link de uma execução vermelha e de uma verde.
Netlify CLI — deploy, --prod e variáveis de autenticação para uso em CI.
No próximo capítulo a esteira ganha exigência: ESLint e Prettier padronizando o código, cobertura de testes, Lighthouse medindo o site publicado antes e depois, logs estruturados, monitoramento de erros em produção e um aviso quando o site sair do ar.
Configurar o ESLint 9 em flat config (eslint.config.js) para uma API Node e para um projeto Vue, e combiná-lo com o Prettier sem que um desfaça o trabalho do outro.
Escrever e executar testes automatizados da API com Vitest e supertest — a mesma pilha da Aula 13 do Nível 3 —, incluindo um teste de integração que sobe o Express de verdade, e medir a cobertura.
Escrever testes de componente e de store com o Vitest e o Vue Test Utils.
Explicar as três métricas do Core Web Vitals (LCP, INP e CLS) e medir uma página publicada com o Lighthouse no DevTools, na linha de comando e no CI.
Aplicar melhorias concretas de performance — imagens, fontes, divisão de código, cache e compressão no nginx — e comprovar o ganho com uma medição antes e depois.
Instrumentar erros de produção com o SDK do Sentry (Node e navegador) e produzir logs estruturados com o pino, com rotação no servidor.
Configurar monitoramento de disponibilidade e publicar robots.txt e sitemap.xml corretos.
[ ] unieventos-api (Express 5) e unieventos-web (Vue 3 + Vite) rodando localmente, com GET /health respondendo { "status": "ok" }.
[ ] Os dois projetos publicados: o site em um endereço HTTPS e a API no Render ou no VPS com nginx (Capítulos 03, 05 e 06).
[ ] Workflows do GitHub Actions funcionando no repositório (Capítulo 09).
[ ] Node 22 LTS na máquina (node -v) e a API já com vitest e supertest instalados (Aula 13 do Nível 3); se ainda não estiverem, a §3.1 mostra o npm install.
[ ] Uma conta de e-mail para criar contas gratuitas no Sentry e em um serviço de uptime.
No Capítulo 09 o GitHub Actions passou a testar e publicar o projeto sozinho a cada push, e ficou uma pergunta em aberto — grande: testar o quê? Um pipeline que roda npm test sem nenhum teste escrito é um carimbo verde que não significa nada. Hoje você preenche esse vazio com quatro instrumentos — linter, testes, Lighthouse e observabilidade — e transforma "o código está bom", "o site parece rápido" e "acho que está no ar" em números que você consegue mostrar para outra pessoa: medição de performance com valor antes e depois, e um painel que avisa quando a produção quebra. Este é o último capítulo técnico da trilha; o próximo trata de usar assistentes de IA sem terceirizar o seu aprendizado.
"O código está bom" e "o site está rápido" são frases sem valor até virarem número. Quatro instrumentos cobrem quatro perguntas diferentes, e é comum confundi-los:
Instrumento
Pergunta que responde
Quando roda
Linter (ESLint)
O código tem erro ou mau hábito sem precisar executá-lo?
Ao digitar, ao salvar, no CI
Testes (Vitest, supertest)
O código faz o que eu disse que faz?
Antes de cada commit e no CI
Lighthouse / Core Web Vitals
A página é rápida e utilizável para quem acessa?
Em cada publicação
Observabilidade (Sentry, pino, uptime)
O que está acontecendo agora, na produção?
O tempo todo, sem você pedir
Repare no que cada um não faz. O linter não sabe se a sua conta está errada; sabe que você declarou uma variável e não usou. O teste não sabe se o site é lento. O Lighthouse mede a página no seu laboratório, com rede simulada — não mede o usuário real no 4G de Sinop. E a observabilidade não previne erro nenhum: conta que o erro aconteceu, para quem e com qual entrada.
Um detalhe que separa quem programa há um mês de quem programa há um ano: os quatro são baratos de instalar e caros de instalar tarde. Configurar ESLint em um projeto de 40 arquivos leva dez minutos e gera três avisos; em um de 400 arquivos, gera oitocentos avisos e vira um dia de trabalho que ninguém quer fazer.
🧠 Você sabia?
O Node passou a ter executor de testes embutido: o módulo node:test, estável desde a linha 20. Antes disso, todo projeto Node precisava de uma biblioteca externa (Mocha, Jest, AVA, Tape) e arrastava dezenas de dependências só para escrever um assert. Hoje node --test roda um arquivo de teste sem nenhum npm install. A trilha continua usando o Vitest na API — porque é ele que o Nível 3 instalou e é o mesmo executor do front —, mas saber que o runner nativo existe muda a resposta para "vale a pena testar este script de 40 linhas?".
O ESLint lê o seu JavaScript, monta a árvore sintática do arquivo e aplica regras. Cada regra tem um nível: off, warn (aparece, não quebra o build) ou error (quebra). Ele pega três famílias de problema: erros que só apareceriam em produção (no-undef, no-unreachable, no-dupe-keys), maus hábitos (no-var, eqeqeq, prefer-const) e convenções de biblioteca (o plugin do Vue avisa quando falta :key em um v-for). O que ele não pega: lógica errada, senha no código, consulta SQL lenta. Linter é o primeiro filtro, não o único.
A versão 9 abandonou o .eslintrc com extends em cascata e adotou o flat config: um único eslint.config.js que exporta um array de objetos de configuração, aplicados de cima para baixo — o último que fala sobre uma regra vence. Não há mais herança mágica de pastas superiores, e cada objeto pode se limitar a certos arquivos com files.
importjsfrom'@eslint/js'importglobalsfrom'globals'importprettierfrom'eslint-config-prettier'exportdefault[// Um objeto só com "ignores" vale para o projeto inteiro.{ignores:['node_modules/','coverage/','dist/']},js.configs.recommended,{files:['**/*.js'],languageOptions:{ecmaVersion:2024,sourceType:'module',globals:{...globals.node},},rules:{'no-var':'error','prefer-const':'error',eqeqeq:['error','always'],'no-unused-vars':['error',{argsIgnorePattern:'^_'}],'no-console':['warn',{allow:['error']}],},},{files:['tests/**/*.js'],rules:{'no-console':'off'}},// Desliga toda regra que brigaria com o Prettier. PRECISA ser o último item.prettier,]
Três decisões merecem explicação. globals.node informa que process, console e URL existem — sem isso, no-undef reclama de tudo (no front o equivalente é globals.browser). argsIgnorePattern: '^_' permite escrever (erro, req, res, _next) no middleware de erro do Express 5 sem que o _next, obrigatório na assinatura e inútil no corpo, vire erro. E 'no-console': 'warn' é um lembrete: na §7 os console.log da API viram chamadas ao pino.
importjsfrom'@eslint/js'importpluginVuefrom'eslint-plugin-vue'importglobalsfrom'globals'importprettierfrom'eslint-config-prettier'exportdefault[{ignores:['node_modules/','dist/','coverage/']},js.configs.recommended,// O preset do Vue já configura o parser de arquivos .vue (vue-eslint-parser)....pluginVue.configs['flat/recommended'],{files:['**/*.{js,vue}'],languageOptions:{ecmaVersion:2024,sourceType:'module',globals:{...globals.browser}},rules:{// As telas do UniEventos se chamam Eventos.vue, Login.vue…'vue/multi-word-component-names':'off','no-unused-vars':['error',{argsIgnorePattern:'^_'}],},},prettier,]
Repare nos três pontos de ...pluginVue.configs['flat/recommended']: o preset é um array de configurações, e o espalhamento coloca cada uma como item do array externo. Sem eles, o ESLint recebe um array dentro do array e falha ao carregar.
O ESLint sabe formatar, mas formatar não é trabalho dele. A divisão que o mercado adotou é: Prettier decide a forma, ESLint decide o conteúdo. Repita o .prettierrc do Capítulo 01 na raiz de cada projeto e acrescente um .prettierignore com node_modules, coverage, dist e package-lock.json. O pacote eslint-config-prettier, importado como último item do array, desliga as regras de estilo do ESLint (indentação, aspas, vírgula final) que apontariam erro exatamente no formato que o Prettier acabou de aplicar — você já viu esse conflito se algum dia salvou um arquivo e o viu ser formatado e sublinhado de vermelho ao mesmo tempo.
Com tudo no lugar, o package.json da API ganha os scripts de qualidade:
unieventos-api/package.json (seção scripts)
JSON
{"scripts":{"dev":"node --watch --env-file=.env src/server.js","start":"node src/server.js","lint":"eslint . --max-warnings=0","lint:corrigir":"eslint . --fix","formatar":"prettier --write .","formatar:conferir":"prettier --check .","test":"vitest run","test:observar":"vitest","test:cobertura":"vitest run --coverage","qualidade":"npm run lint && npm run formatar:conferir && npm test"}}
npm run qualidade é o comando que você roda antes de abrir um pull request — e é exatamente o que o CI vai rodar. Quando os dois são o mesmo comando, ninguém é surpreendido pelo robô. O --fix resolve o mecânico (aspas, let que podia ser const) e deixa para você o que exige decisão: uma variável não usada pode ser lixo ou pode ser um bug.
🔎 Por baixo do capô
O && entre os scripts não é decoração: ele encadeia processos e para no primeiro que devolver código de saída diferente de zero. Todo comando de terminal devolve esse código, e é assim que o GitHub Actions decide se um step passou. Teste: rode npm run lint em um projeto com erro e depois echo $? — vai imprimir 1. Depois de um comando bem-sucedido, 0.
⚠️ Atenção
A extensão ESLint do VS Code usa o eslint.config.js da pasta aberta no editor. Se você abrir uma pasta que contém unieventos-web e unieventos-api lado a lado, ela procura um único arquivo na raiz e não acha nenhum dos dois. Abra um projeto por janela.
A pilha de testes da API é a mesma desde a Aula 13 do Nível 3 — Vitest como executor e supertest para bater nas rotas —, e é a mesma do front (§4): um executor só para o projeto inteiro, uma configuração só, um npm test só. Se a sua API ainda não tem os dois:
💡 Dica
Para projetos sem dependência nenhuma — um script de manutenção, um utilitário de linha de comando —, o Node 22 traz um runner embutido: import { describe, it } from 'node:test', import assert from 'node:assert/strict' e node --test para executar, sem npm install. A API da trilha fica no Vitest (é o que o Nível 3 instalou e o que o front usa), mas o node:test é a alternativa certa quando adicionar uma devDependency não compensa.
Você não vai testar tudo, e não precisa. A ordem que rende mais por hora investida: primeiro as funções puras com regra de negócio (paginação, cálculo de vagas, validação, normalização de texto) — baratas de testar e onde moram os bugs sutis; depois as rotas da API pelo contrato (o GET devolve 200 e um array? o POST sem título devolve 400 com mensagem?), que pegam quase todo erro de integração; e sempre a regra de ouro: todo bug corrigido vira um teste — antes de arrumar, escreva o teste que falha por causa dele.
O que não vale a pena no nosso tamanho: testar getters triviais, testar bibliotecas de terceiros e testar telas ponta a ponta (isso exige Playwright ou Cypress).
/** Recorta uma lista em páginas. Sempre devolve uma página válida, mesmo com entrada absurda. */exportfunctionpaginar(lista,pagina=1,porPagina=10){consttotal=lista.lengthconstpaginas=Math.max(1,Math.ceil(total/porPagina))constatual=Math.min(Math.max(1,Number(pagina)||1),paginas)constinicio=(atual-1)*porPaginareturn{itens:lista.slice(inicio,inicio+porPagina),total,pagina:atual,paginas}}
O teste vive em tests/, com a terminação .test.js — um dos padrões que o Vitest encontra sozinho:
unieventos-api/tests/paginacao.test.js
JavaScript
import{describe,it,expect}from'vitest'import{paginar}from'../src/util/paginacao.js'consttrinta=Array.from({length:30},(_,i)=>({id:i+1}))describe('paginar',()=>{it('devolve os 10 primeiros itens na página 1',()=>{constr=paginar(trinta,1,10)expect(r.itens).toHaveLength(10)expect(r.itens[0].id).toBe(1)expect(r.paginas).toBe(3)expect(r.total).toBe(30)})it('trata lista vazia sem quebrar',()=>{constr=paginar([],5,10)expect(r.itens).toEqual([])expect(r.pagina).toBe(1)expect(r.paginas).toBe(1)})it('grampeia páginas fora do intervalo e entradas inválidas',()=>{expect(paginar(trinta,99,10).pagina).toBe(3)expect(paginar(trinta,'abacaxi',10).pagina).toBe(1)})})
toBe compara com Object.is (na prática, ===) e toEqual compara estruturas campo a campo. Prefira sempre a comparação estrita: em um expect('1').toEqual(1) o teste falha, e é isso que você quer — um teste que passa por engano é pior do que nenhum teste.
Este vale por dez: o supertest sobe o Express de verdade em uma porta livre, faz uma requisição HTTP real e derruba tudo no fim, sem você administrar servidor nem porta. Ele exige que src/app.jsexporte o app sem chamar listen — a separação que a unieventos-api já tem desde o Nível 3 (export const app), e que existe justamente para isto; quem chama listen é o src/server.js.
unieventos-api/tests/eventos.test.js
JavaScript
import{describe,it,expect}from'vitest'importrequestfrom'supertest'import{app}from'../src/app.js'describe('GET /api/eventos',()=>{it('responde 200, JSON e uma lista',async()=>{constresposta=awaitrequest(app).get('/api/eventos')expect(resposta.status).toBe(200)expect(resposta.headers['content-type']).toMatch(/application\/json/)expect(Array.isArray(resposta.body.itens)).toBe(true)})})describe('POST /api/eventos',()=>{it('recusa evento sem título com 400 e mensagem',async()=>{constresposta=awaitrequest(app).post('/api/eventos').send({local:'Anfiteatro'})expect(resposta.status).toBe(400)expect(resposta.body.erro).toMatch(/t[íi]tulo/i)})})
O request(app) abre um servidor em uma porta efêmera, dispara a requisição e fecha tudo sozinho — testes nunca devem brigar por porta fixa. Quatro asserções cobrem o contrato inteiro de uma rota: código de status, tipo de conteúdo, formato do corpo e comportamento com entrada errada.
npxvitestrun# roda tudo que parece teste e sai
npxvitest# modo interativo: reexecuta ao salvar
npxvitestrun-t"paginar"# só os testes cujo nome bate
npxvitestrun--coverage# com relatório de cobertura
vitest run executa uma vez e sai — é a forma que o CI precisa, porque o modo interativo nunca terminaria. A cobertura sai em tabela no fim, com a porcentagem de linhas, ramos e funções por arquivo e as linhas não cobertas. Use-a como mapa, não como meta: 100 % com asserções fracas não prova nada, e 60 % nas rotas certas já protege o essencial.
🔬 Investigue
Rode npm run test:cobertura na sua API e olhe a coluna de linhas não cobertas do arquivo de rotas. Escolha uma linha não coberta que trate erro (um if de validação, um catch) e escreva o teste que a executa. Rode de novo e veja a porcentagem subir. Cronometre: quase sempre são menos de cinco minutos por teste — e essa é a resposta para "não tenho tempo de testar".
No front o executor é o mesmo — Vitest —, mas a configuração muda: ele reaproveita a do Vite (aliases, plugins, variáveis de ambiente) e precisa de um DOM de mentira para montar componentes Vue.
import{fileURLToPath}from'node:url'import{mergeConfig,defineConfig,configDefaults}from'vitest/config'importviteConfigfrom'./vite.config.js'exportdefaultmergeConfig(viteConfig,defineConfig({test:{// jsdom simula document/window dentro do Node: é o "navegador de mentira".environment:'jsdom',globals:true,setupFiles:['./vitest.setup.js'],exclude:[...configDefaults.exclude,'e2e/**'],root:fileURLToPath(newURL('./',import.meta.url)),},}),)
unieventos-web/vitest.setup.js
JavaScript
// O Vuetify observa o tamanho dos elementos; o jsdom não implementa ResizeObserver.// Sem este substituto, montar qualquer componente Vuetify quebra com ReferenceError.global.ResizeObserver=class{observe(){}unobserve(){}disconnect(){}}
import{describe,it,expect}from'vitest'import{mount}from'@vue/test-utils'import{createVuetify}from'vuetify'import*ascomponentsfrom'vuetify/components'import*asdirectivesfrom'vuetify/directives'importEventoCardfrom'../EventoCard.vue'constvuetify=createVuetify({components,directives})functionmontar(evento){returnmount(EventoCard,{global:{plugins:[vuetify]},props:{evento}})}describe('EventoCard',()=>{it('mostra o título e o local do evento',()=>{constwrapper=montar({id:1,titulo:'Semana Acadêmica',local:'Anfiteatro',vagas:30})expect(wrapper.text()).toContain('Semana Acadêmica')expect(wrapper.text()).toContain('Anfiteatro')})it('desabilita a inscrição quando não há vagas',()=>{constwrapper=montar({id:2,titulo:'Oficina de Cafés',local:'Lab 3',vagas:0})expect(wrapper.get('button').attributes('disabled')).toBeDefined()})it('emite "inscrever" com o id ao clicar',async()=>{constwrapper=montar({id:7,titulo:'Palestra',local:'Auditório',vagas:5})awaitwrapper.get('button').trigger('click')expect(wrapper.emitted('inscrever')[0]).toEqual([7])})})
Uma store da Pinia se testa do mesmo jeito, sem rede: setActivePinia(createPinia()) em um beforeEach (cada teste precisa começar com estado limpo), depois atribua store.eventos na mão e verifique os getters. É o teste mais barato do front e pega quase todo erro de filtro e de total.
Scripts do front: "test:unit": "vitest run", "test:observar": "vitest", "test:cobertura": "vitest run --coverage" e um qualidade encadeando lint, formatação e testes. O vitest sem argumento entra em modo interativo e reexecuta ao salvar; vitest run executa uma vez e sai — é a forma que o CI precisa, porque o modo interativo nunca terminaria.
O Google padronizou três medidas de experiência real, os Core Web Vitals, escolhidas porque respondem a três perguntas que o usuário faz sem perceber:
Métrica
Pergunta do usuário
Bom / Ruim
LCP (Largest Contentful Paint)
"Já carregou?" — quando o maior elemento visível aparece
≤ 2,5 s / > 4,0 s
INP (Interaction to Next Paint)
"Está travado?" — quanto a página demora a reagir a um clique
≤ 200 ms / > 500 ms
CLS (Cumulative Layout Shift)
"Por que o botão fugiu?" — quanto o conteúdo pula durante a carga
≤ 0,1 / > 0,25
Os limites valem para o percentil 75 dos carregamentos: três de cada quatro visitas precisam ficar na faixa boa. O INP substituiu o antigo FID, que media só o atraso da primeira interação — um número fácil de acertar e que escondia páginas travadas do segundo clique em diante.
Há duas fontes de dado, bem diferentes. Laboratório: o Lighthouse roda a página na sua máquina, com CPU e rede simuladas — reproduzível, imediato e não representa ninguém em particular. Campo: medições de visitantes reais; o PageSpeed Insights mostra os dados do CrUX quando o site tem tráfego suficiente, o que provavelmente não é o caso do seu projeto de estudo (para coletar os seus, use a biblioteca web-vitals). O Lighthouse não consegue medir INP em laboratório — não há usuário clicando — e usa o TBT (Total Blocking Time) como aproximação; a nota de 0 a 100 é uma média ponderada em que TBT, LCP e CLS pesam mais, e por isso um site com JavaScript pesado perde pontos mesmo pintando rápido.
🧠 Você sabia?
A pontuação de performance do Lighthouse é comparativa: posiciona o seu site em uma curva construída a partir de milhões de páginas reais. Por isso ela é implacável na faixa alta — sair de 90 para 95 costuma dar mais trabalho do que sair de 40 para 70. E por isso a nota varia entre execuções na mesma página: rede, CPU disponível e até uma extensão do navegador mudam o resultado. Rode três vezes e use a mediana; o Lighthouse CI faz isso por você.
Isso grava relatorios/antes.report.html (para abrir no navegador) e antes.report.json (para extrair números). Sem --preset=desktop, o Lighthouse simula um celular mediano com rede lenta — que é o cenário certo para a maioria de quem acessa pela primeira vez.
No CI, com o Lighthouse CI, que roda várias vezes, tira a mediana e reprova o build se a nota cair:
unieventos-web/lighthouserc.json
JSON
{"ci":{"collect":{"startServerCommand":"npm run preview","url":["http://localhost:4173/","http://localhost:4173/eventos"],"numberOfRuns":3},"assert":{"preset":"lighthouse:no-pwa","assertions":{"categories:performance":["error",{"minScore":0.9}],"categories:accessibility":["error",{"minScore":0.9}]}},"upload":{"target":"temporary-public-storage"}}}
O autorun sobe o servidor de preview, roda o Lighthouse três vezes em cada URL, compara com as asserções e imprime um link público temporário para o relatório. No GitHub Actions vira um workflow:
.github/workflows/qualidade.yml
YAML
name:qualidadeon:push:branches:[main]pull_request:jobs:verificar:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v4-uses:actions/setup-node@v4with:node-version:22cache:npm-run:npm ci-run:npm run lint-run:npm run formatar:conferir-run:npm run test:unit-run:npm run build-run:npx lhci autorun
Não adianta otimizar no escuro. A ordem abaixo é a que mais rende em projetos do nosso tamanho, e cada item diz qual métrica ele move.
1. Dimensione e converta as imagens (LCP). Uma foto de 3000 px exibida em uma caixa de 600 px transfere nove vezes mais bytes do que precisa. Redimensione para o dobro da largura de exibição (por causa das telas de alta densidade), converta para WebP ou AVIF e sirva com <picture>:
unieventos-web/src/components/Hero.vue (trecho do template)
HTML
<picture><sourcesrcset="/img/hero-1200.avif"type="image/avif"/><sourcesrcset="/img/hero-1200.webp"type="image/webp"/><imgsrc="/img/hero-1200.jpg"alt="Estudantes na abertura da Semana Acadêmica"width="1200"height="600"fetchpriority="high"/></picture>
2. Declare width e height em toda imagem (CLS). Sem elas o navegador reserva zero altura, pinta o texto e empurra tudo para baixo quando a imagem chega. Os atributos não fixam o tamanho visual (o CSS continua mandando com width: 100%; height: auto); informam a proporção, e o navegador reserva o espaço certo.
3. loading="lazy" fora da dobra, nunca na imagem do LCP. Imagens que só aparecem depois de rolar ganham loading="lazy"; a principal do topo recebe o oposto, fetchpriority="high" e nenhum lazy. Adiar a imagem do LCP é o erro de otimização mais comum — piora justamente a métrica que você queria melhorar.
4. Controle as fontes (LCP e CLS). Fonte externa bloqueia texto. No <head>:
O display=swap manda mostrar o texto imediatamente com a fonte de sistema e trocar quando a personalizada chegar. Melhor ainda: baixe os .woff2 para public/fontes/, declare @font-face com font-display: swap e um <link rel="preload" as="font" crossorigin> — some duas conexões externas e um risco a menos de indisponibilidade.
5. Divida o JavaScript por rota (TBT/INP). No Vue Router, troque a importação estática pela dinâmica: o Vite gera um arquivo por rota e o navegador só baixa a tela que o usuário abriu.
unieventos-web/src/router/index.js
JavaScript
import{createRouter,createWebHistory}from'vue-router'importIniciofrom'../views/Inicio.vue'constrouter=createRouter({history:createWebHistory(import.meta.env.BASE_URL),routes:[// A tela inicial vem no pacote principal: é sempre a primeira a abrir.{path:'/',name:'inicio',component:Inicio},// As demais viram arquivos separados, baixados sob demanda.{path:'/eventos',name:'eventos',component:()=>import('../views/Eventos.vue')},{path:'/eventos/:id',name:'evento',component:()=>import('../views/Evento.vue')},{path:'/admin',name:'admin',component:()=>import('../views/Admin.vue')},],})exportdefaultrouter
Rode npm run build antes e depois: a saída do Vite lista o tamanho de cada arquivo gerado, e você vê o pacote principal encolher.
6. Cabeçalhos de cache no nginx (visitas seguintes). O Vite coloca um hash no nome de cada arquivo gerado (index-B7fK2p.js). Como o nome muda sempre que o conteúdo muda, esses arquivos podem ser guardados por um ano sem risco. O index.html, que aponta para eles, não pode ser guardado nunca.
/etc/nginx/sites-available/unieventos (trecho do server)
text/html não entra na lista porque o nginx sempre o comprime. Não comprima imagens: JPEG, PNG e WebP já são formatos comprimidos, e passar gzip neles gasta CPU para economizar quase nada. O Brotli comprime cerca de 15 % melhor em texto, mas não vem no nginx padrão: depende do módulo ngx_brotli, que algumas distribuições empacotam e outras exigem compilar. Se a sua não tem, siga com gzip. Em Netlify, Vercel, Cloudflare Pages e GitHub Pages a compressão já vem ligada e negociada automaticamente.
🔬 Investigue
Rode o curl acima em três sites: o seu, o do WebLab e o portal da sua universidade ou escola. Compare content-encoding e cache-control. Depois, no DevTools → Network, clique em um arquivo .js e compare Size (o que trafegou) com o tamanho descomprimido: a razão entre os dois é a taxa de compressão. Qual dos três comprime melhor?
Em produção o console.log da API vai para um arquivo que ninguém lê, e o console.error do navegador morre no computador do usuário. Você fica sabendo do erro quando alguém reclama — se reclamar. Uma ferramenta de rastreamento inverte isso: o erro chega até você com pilha de chamadas, navegador, rota e quantas pessoas foram atingidas.
O Sentry tem plano gratuito suficiente para projetos de estudo. Crie a conta, crie dois projetos (um node e um vue) e guarde os dois DSN — a URL que identifica o projeto para onde os eventos são enviados.
Terminal
cd~/weblab/unieventos-api
npminstall@sentry/node
O SDK precisa ser inicializado antes de qualquer outro import, porque instrumenta os módulos na hora em que são carregados. Por isso ele mora em um arquivo separado, carregado pelo Node antes do programa:
unieventos-api/instrument.js
JavaScript
import*asSentryfrom'@sentry/node'Sentry.init({dsn:process.env.SENTRY_DSN,environment:process.env.NODE_ENV??'development',release:process.env.APP_VERSION??'dev',// Fração das requisições que vira "trace" de performance. 0.1 = 10 %.tracesSampleRate:0.1,// Não envie IP, cookies nem corpo de requisição por padrão (LGPD).sendDefaultPii:false,// Sem DSN configurado, o SDK fica inerte — ótimo para rodar testes localmente.enabled:Boolean(process.env.SENTRY_DSN),})
No package.json, o script start passa a carregá-lo: "start": "node --import ./instrument.js src/server.js". O --import executa o módulo antes do arquivo principal e é a forma recomendada com módulos ES. Depois de registrar todas as rotas, e antes do seu middleware de erro:
unieventos-api/src/app.js (trecho final)
JavaScript
// Captura as exceções que escaparem das rotas; vem ANTES do seu middleware de erro.Sentry.setupExpressErrorHandler(app)app.use((erro,req,res,_next)=>{conststatus=erro.status??500// Por enquanto, console.error; na §7.2 o pino-http cria req.log e esta// linha vira req.log.error({ err: erro }, 'erro não tratado').console.error('erro não tratado',erro)res.status(status).json({erro:status===500?'Erro interno':erro.message})})
Lembre do Express 5: erros lançados dentro de handlers async vão automaticamente para o middleware de erro, sem .catch(next) — o que significa que o Sentry vê todos eles. Para algo que você tratou mas quer acompanhar, use Sentry.captureException(erro, { tags: { etapa: 'email-confirmacao' } }).
O DSN do navegador é público por natureza: vai no JavaScript que qualquer pessoa baixa, e só permite enviar eventos. O que nunca pode aparecer no front é o token de autenticação da API do Sentry. E sem source map a pilha chega ilegível (t.e is not a function, linha 1): gere os mapas no build e envie-os com o plugin oficial do Sentry para Vite, e o painel passa a mostrar o seu código original.
⚠️ Atenção
Não mande dado pessoal para o Sentry. sendDefaultPii: false é o padrão e deve continuar assim. Se usar Sentry.setUser, passe um identificador interno ({ id: usuario.id }), não o e-mail. E revise as mensagens da sua própria API: Usuário maria@exemplo.com não encontrado vira vazamento no momento em que essa string sobe para um serviço de terceiros.
Usuário logou às 3 da tarde é ótimo para ler uma linha e péssimo para responder "quantos logins falharam na última hora?". Log estruturado resolve: cada linha é um JSON com campos fixos, e a busca vira filtro.
importpinofrom'pino'exportconstlog=pino({level:process.env.LOG_LEVEL??'info',// Campos que nunca podem sair no log, por mais fundo que estejam no objeto.redact:{paths:['req.headers.authorization','req.headers.cookie','*.senha','*.token'],censor:'[oculto]',},// Nome do serviço em toda linha: essencial quando dois processos escrevem no mesmo lugar.base:{servico:'unieventos-api'},})
Com o middleware registrado, o console.error do tratador de erro da §7.1 vira req.log.error({ err: erro }, 'erro não tratado') — a mesma linha, agora amarrada ao identificador da requisição. No src/app.js, app.use(pinoHttp({ logger: log })) antes de tudo gera uma linha por requisição, com método, rota, status e duração. Dentro de qualquer rota, req.log é um logger já preenchido com o identificador daquela requisição: req.log.info({ inscricaoId: inscricao.id }, 'inscrição criada') produz uma linha JSON com level, time, servico, reqId, inscricaoId e msg. Os níveis são numéricos: trace 10, debug 20, info 30, warn 40, error 50, fatal 60. Definir LOG_LEVEL=warn faz o pino descartar tudo abaixo de 40 sem nem formatar a mensagem — por isso ele é rápido o bastante para ficar ligado em produção.
Terminal
nodesrc/server.js|npxpino-pretty# legível no desenvolvimento
grep'"level":50'~/.pm2/logs/unieventos-api-out.log|tail-20# erros no servidor
tail-500~/.pm2/logs/unieventos-api-out.log|jq-c'select(.level >= 40) | {msg, url: .req.url}'
Um arquivo de log cresce para sempre, e servidor de projeto pequeno para por disco cheio de log — o sintoma é bonito: tudo funciona, até que nada funciona. Com pm2 (Capítulo 06), o módulo oficial resolve; com o logrotate do sistema, que já roda diariamente no Ubuntu, é um arquivo de configuração:
copytruncate é a chave quando o processo mantém o arquivo aberto (o caso do pm2): o logrotate copia o conteúdo e zera o original, em vez de renomeá-lo e deixar o processo escrevendo em um arquivo invisível. Teste sem esperar um dia com sudo logrotate -d /etc/logrotate.d/unieventos (simula) e -f (força). Se a sua API roda como serviço systemd e escreve na saída padrão — o laboratório dsw-gN do Capítulo 06 —, o journald já faz a rotação (journalctl -u dsw-g3 -n 100 --no-pager).
Um monitor de disponibilidade chama uma URL em intervalos fixos e avisa quando a resposta muda. Configuração mínima: alvohttps://api.seudominio.dev/health (não a home do site — uma home estática continua respondendo 200 com a API caída); intervalo de 5 minutos, que é o do plano gratuito do UptimeRobot; condição de alerta status diferente de 200 ou corpo sem a palavra ok, o que pega o caso em que o processo responde mas o banco caiu; e pelo menos um canal que apite no celular. O UptimeRobot dá 50 monitores no plano gratuito; o Better Stack dá menos monitores, intervalo menor e uma página de status pública — detalhe profissional barato, que deixa qualquer pessoa descobrir se o problema é o sistema ou a internet dela.
Vale melhorar o /health para que ele signifique alguma coisa:
unieventos-api/src/rotas/saude.js
JavaScript
import{Router}from'express'import{pool}from'../db/pool.js'constrotas=Router()rotas.get('/health',async(req,res)=>{try{// Uma consulta trivial prova que a conexão com o banco está viva.awaitpool.query('SELECT 1')res.json({status:'ok',versao:process.env.APP_VERSION??'dev'})}catch(erro){req.log.error({err:erro},'health check falhou')res.status(503).json({status:'degradado',detalhe:'banco indisponível'})}})exportdefaultrotas
O robots.txt fica na raiz do domínio e diz aos rastreadores o que podem visitar. Ele não protege nada — é um pedido, não uma tranca.
Em um ambiente de teste ou homologação o arquivo é outro (User-agent: * seguido de Disallow: /) — esquecer disso é como um endereço de rascunho aparece no Google. O sitemap lista as URLs que você quer indexadas; em um site pequeno pode ser escrito à mão:
Se o seu site tem páginas geradas a partir do banco (uma por evento), escreva um script que gere o arquivo no build e inclua a tag <lastmod> com a data da última alteração de cada registro — assim o buscador sabe o que revisitar. Arquivo gerado por script não erra e não envelhece.
📌 Vale gravar
Saber diferenciar as três métricas do Core Web Vitals pela pergunta que cada uma responde; saber que dado de laboratório (Lighthouse) e dado de campo (usuários reais) medem coisas diferentes; e explicar por que robots.txt não é mecanismo de segurança.
🚀 Passo a passo — UniEventos medido, testado e vigiado¶
Ao fim destes passos você tem: linter e formatador nos dois projetos, testes rodando local e no CI, um relatório Lighthouse antes e outro depois com a diferença explicada, erros de produção chegando ao seu e-mail e um monitor apitando se a API cair.
Está no Nível 2? Aplique o mesmo passo na cafe-cerrado-api e no front do Café Cerrado: o linter, os testes com Vitest e supertest, o Lighthouse e o Sentry não dependem do domínio do projeto — só das rotas que você tem.
Passo 1 — Linha de base (faça antes de mexer em qualquer coisa)¶
Esperado: a primeira execução lista problemas; a última não lista nenhum. Leia cada erro que o --fix não resolveu — quase sempre há uma variável não usada revelando código morto. Acrescente os scripts de qualidade aos dois package.json.
Na API, garanta vitest, supertest e @vitest/coverage-v8 instalados e crie tests/paginacao.test.js e tests/eventos.test.js (§3), adaptando os nomes de rota. No front, instale vitest, @vue/test-utils, jsdom e @vitest/coverage-v8 e crie vitest.config.js, vitest.setup.js e o teste de componente da §4.
Complete o comparativo.md com uma tabela antes/depois e uma frase por linha dizendo qual correção causou aquela mudança. É esse documento, não a nota, que prova que você entendeu.
Crie os dois projetos no Sentry. Na API: instrument.js, SENTRY_DSN no .env (e nas variáveis do Render ou do serviço systemd), start com --import e Sentry.setupExpressErrorHandler(app). No front: VITE_SENTRY_DSN e o Sentry.init da §7.1. Para conferir, crie uma rota /api/erro-de-teste que lança throw new Error('teste de instrumentação'), chame-a com curl, confirme que o evento aparece no painel em segundos e apague a rota em seguida.
Instale pino e pino-http, crie src/log.js, registre o middleware e troque os console.log restantes por req.log ou log. No servidor, configure pm2-logrotate ou /etc/logrotate.d/unieventos. Crie o .github/workflows/qualidade.yml e o lighthouserc.json (§5.2), faça o push e confira a marca verde no pull request. Por fim, cadastre o monitor de /health no UptimeRobot com alerta por e-mail.
A1. No eslint.config.js da API, o objeto prettier está no fim do array. O que acontece se você movê-lo para antes de js.configs.recommended? Justifique com a regra de precedência do flat config.
A2. Classifique cada problema como "o linter pega", "o teste pega", "o Lighthouse pega" ou "só a observabilidade pega": (a) if (idade = 18) com um só sinal de igual; (b) a paginação devolve a página 0 com lista vazia; (c) a imagem do topo tem 2,4 MB; (d) a API devolve 500 apenas quando o usuário tem acento no nome.
A3.expect('3').toBe(3) passa ou falha no Vitest? E assert.equal('3', 3) com node:assert/strict e com o node:assert comum? Explique por que um comparador que coage tipos torna um teste perigoso.
A4. Uma página tem LCP de 1,8 s, INP de 90 ms e CLS de 0,32. Quais métricas estão na faixa boa e qual é o provável culpado do valor ruim?
A5. Explique em uma frase por que loading="lazy" na imagem do topo piora o LCP, enquanto a mesma propriedade nas imagens do rodapé melhora o carregamento.
A6. Os arquivos gerados pelo Vite têm hash no nome (index-B7fK2p.js). Como isso permite Cache-Control: public, immutable por um ano sem prender o usuário a uma versão antiga? E por que o index.html não pode receber o mesmo cabeçalho?
B1. Cobertura dirigida. Rode npm run test:cobertura na sua API, escolha o arquivo com a menor cobertura de linhas e escreva três testes que subam esse número em pelo menos 20 pontos percentuais. Pelo menos um deve exercitar um caminho de erro (entrada inválida, recurso inexistente).
Resultado esperado: a tabela de cobertura antes e depois, colada no README.md, com a diferença destacada.
Dica
A saída da cobertura lista, na última coluna, os intervalos de linhas não executadas. Abra o arquivo nessas linhas: quase sempre são if de validação e blocos catch. Para exercitar um catch de banco, extraia a regra para uma função pura e teste-a diretamente, ou aponte a conexão para um host inválido em um teste isolado.
B2. Um linter que reprova de propósito. Introduza três problemas no seu código — uma variável declarada e não usada, um == em vez de === e um var — e comprove que npm run lint falha com código de saída diferente de zero (echo $?). Depois corrija dois com --fix e explique por que o terceiro exigiu decisão sua.
Resultado esperado: o log dos dois npm run lint (antes e depois) e um parágrafo dizendo qual problema o --fix não resolveu e por quê.
Dica
O --fix só aplica correções que a regra declara como seguras e sem ambiguidade. Trocar var por let e == por === muda comportamento em casos-limite, então nem toda regra oferece correção automática. A documentação de cada regra tem um selo indicando se ela é corrigível.
B3. Orçamento de performance. Adicione ao seu lighthouserc.json asserções que reprovem o build se a nota de performance cair abaixo de 0,9, a de acessibilidade abaixo de 0,95 ou o LCP passar de 2500 ms. Faça um commit que quebre uma delas de propósito (trocando a imagem do topo por uma versão gigante, por exemplo) e mostre o CI vermelho.
Resultado esperado: dois links de execução do workflow — um verde e um vermelho — e a mensagem exata da asserção que falhou.
Dica
Em ci.assert.assertions você pode citar auditorias individuais pelo id, como largest-contentful-paint, no formato ["error", { "maxNumericValue": 2500 }]. Os ids aparecem na chave audits do relatório JSON.
C1. Bug plantado, teste primeiro. Peça a um colega que introduza um bug em uma função de regra de negócio da sua API, sem dizer qual. Sua missão, em ordem obrigatória: (1) descubra o bug pelo comportamento, não lendo o diff; (2) escreva um teste que falha por causa dele; (3) só então corrija; (4) confirme que o teste passa. Troque de papel e repita. No fim, os dois repositórios têm um teste novo cada, com uma mensagem de commit explicando o bug capturado.
Dica
Este é o ciclo de correção guiada por teste, e a ordem importa: se você corrigir antes de escrever o teste, nunca vai saber se ele realmente detectaria o problema. Um teste que nunca foi visto falhando é um teste em que não se pode confiar. Para achar o bug sem ler o diff, rode os testes que já existem — se todos passam, o bug está em um caminho que ninguém cobriu, e isso já é uma pista.
Todo mundo diz que "otimizou o site". Quase ninguém consegue mostrar o número. Hoje você mostra. Pegue o seu projeto autoral publicado, meça, aplique exatamente três correções de performance e meça de novo — provando qual das três rendeu mais.
Critérios de pronto
Dois relatórios do Lighthouse guardados no repositório (relatorios/antes.report.html e depois.report.html), gerados pela CLI com o mesmo comando.
Um comparativo.md com uma tabela de no máximo quatro colunas: métrica, antes, depois, correção responsável.
As três correções são de categorias diferentes (uma de imagem, uma de rede/cabeçalho, uma de JavaScript ou fonte).
Uma seção "o que não funcionou" com pelo menos uma tentativa que não mudou o número, e a hipótese de por quê.
A nota foi medida três vezes em cada momento e a tabela usa a mediana.
Pistas
Comece pela aba Network com Slow 4G: ordene por Size e olhe os três maiores arquivos. É quase sempre lá que está o ganho fácil.
O relatório do Lighthouse estima a economia em segundos de cada oportunidade ("Properly size images", "Eliminate render-blocking resources"). Use a estimativa para escolher as três correções.
Para converter imagens sem instalar nada, use https://squoosh.app; compare WebP com qualidade 75 e AVIF com qualidade 50.
O seu projeto autoral não termina no primeiro deploy: se der certo, você (ou outra pessoa) vai voltar a mexer nele daqui a alguns meses. Sem testes, cada mudança futura é uma aposta. Construa a rede de segurança: uma suíte que roda em segundos, cobre o que importa e trava o pull request quando alguém quebra alguma coisa.
Critérios de pronto
Pelo menos 10 testes: no mínimo 4 de unidade sobre regra de negócio e 4 de integração cobrindo os quatro verbos do CRUD principal.
Pelo menos dois testes verificam falha: entrada inválida devolvendo 400 com mensagem, e acesso sem autenticação devolvendo 401.
npm test roda sem depender de banco de produção nem de rede externa (banco de teste, arquivo temporário ou dados em memória).
A suíte inteira termina em menos de 15 segundos.
Um workflow no GitHub Actions roda lint, formatação e testes a cada pull request; a branch main está protegida exigindo esse workflow verde.
O README.md tem uma seção "Como rodar os testes" de no máximo 8 linhas.
Pistas
Para isolar o banco, exporte a criação do pool de uma função que aceite a URL de conexão; nos testes, passe a de um banco _teste recriado no before.
Um beforeEach com TRUNCATE (ou a recriação do arquivo JSON) evita que um teste dependa da ordem de execução — causa número um de suíte instável.
Um teste que precisa de token pode gerar um token válido com a mesma função que a API usa, sem passar pelo login.
Em Settings → Branches → Add rule, marque Require status checks to pass before merging e escolha o job do workflow.
Sistemas profissionais têm uma página que responde, em cinco segundos de olhada, "está tudo bem?". Construa a sua para o projeto autoral: uma página /status que reúne disponibilidade, desempenho e erros — e que você consiga defender em uma apresentação de cinco minutos.
Critérios de pronto
GET /health verifica de verdade a dependência crítica (banco) e devolve 503 quando ela cai; comprove derrubando o banco de propósito.
Métricas de campo coletadas do navegador real com a biblioteca web-vitals e enviadas para a sua própria API, que as grava.
Uma página /status mostrando LCP e CLS medianos das últimas visitas, contagem de erros nas últimas 24 horas e o tempo desde o último incidente.
Sentry configurado nos dois lados, com source maps enviados no build, de modo que a pilha de um erro do front mostre o seu código original.
Um alerta (Sentry ou uptime) que chega no seu celular, comprovado com uma captura de tela.
Um OBSERVABILIDADE.md de no máximo uma página explicando o que você faria com 10 vezes mais usuários.
Pistas
import { onLCP, onINP, onCLS } from 'web-vitals' e, no callback, navigator.sendBeacon('/api/metricas', JSON.stringify(metrica)) — o sendBeacon sobrevive ao fechamento da aba.
Grave as métricas em uma tabela simples (nome, valor, rota, dispositivo) e calcule a mediana em SQL com ORDER BY e LIMIT/OFFSET, ou em JavaScript mesmo.
Para os source maps, o plugin oficial do Sentry para Vite recebe organização, projeto e um token de autenticação — que vive em um secret do GitHub Actions, nunca no repositório.
Este é o tipo de material que fecha bem o Marco final da sua trilha.
No repositório do seu projeto autoral (front e API):
Configure ESLint 9 em flat config e Prettier nos dois projetos, com os scripts lint, formatar:conferir e qualidade. Rode npm run lint:corrigir, corrija o que sobrar e comite em um commit separado chamado chore: lint e formatação.
Escreva no mínimo quatro testes: dois de unidade sobre uma regra de negócio sua e dois de integração sobre a rota principal da API (um caminho feliz e um de erro).
Meça o site publicado com a CLI do Lighthouse, aplique duas correções de performance e meça de novo. Guarde os dois relatórios em relatorios/ e escreva o comparativo.md.
Configure o Sentry no front (é o mais rápido dos dois) e force um erro para confirmar que o evento chega.
Critério de pronto:npm run qualidade termina sem erro nos dois projetos; relatorios/comparativo.md mostra a nota antes e depois com as duas correções nomeadas; existe uma captura de tela do evento no painel do Sentry.
Guarde no seu repositório: commit + push, com o comparativo.md visível na raiz e a captura em relatorios/.
No próximo capítulo a trilha fecha com a ferramenta mais nova e mais mal usada da caixa: assistentes de inteligência artificial. Você vai aprender a dar contexto em um prompt, a desconfiar de API inventada, a usar a IA para revisar o seu projeto autoral — e a regra que vale nos três Níveis do WebLab sobre o que é apoio e o que é cola.
Explicar, em termos práticos, o que um modelo de linguagem faz ao gerar código e por que "responder com confiança" não é o mesmo que "responder certo".
Escolher entre os três formatos de assistente — chat, autocompletar no editor e agente que edita arquivos — de acordo com a tarefa, sabendo o que cada um enxerga do seu projeto.
Escrever um prompt com os seis elementos que mudam a qualidade da resposta: objetivo, código real, versões, erro literal, o que você já tentou e o formato esperado.
Usar o assistente com proveito em cinco tarefas concretas: explicar um erro, revisar código, gerar testes, escrever mensagem de commit e README, e aprender uma API nova.
Reconhecer os modos típicos de falha — API alucinada, versão desatualizada, pacote inexistente, código plausível e inseguro — e checar cada um na documentação oficial em menos de dois minutos.
Aplicar as regras de segurança e privacidade sobre o que nunca entra em um prompt (segredos, .env, dados pessoais reais) e o que fazer se algo escapar.
Adotar uma política pessoal de uso de IA como estudante: apoio bem-vindo, mas autoria e capacidade de explicar são o que provam — para você mesmo, antes de qualquer outra pessoa — que o aprendizado aconteceu.
[ ] Seu projeto autoral versionado no GitHub, com front e API, rodando localmente (npm run dev e npm start).
[ ] O projeto publicado, ainda que parcialmente (Capítulos 03, 05, 06 ou 07).
[ ] npm test e npm run lint configurados como no Capítulo 10 — este capítulo usa os dois para conferir o que a IA propõe.
[ ] Uma conta em pelo menos um assistente de chat com plano gratuito, e o navegador com a documentação oficial da sua stack aberta em outra aba.
[ ] Git limpo (git status sem alterações pendentes) antes de começar o Passo a passo.
No Capítulo 10 você instrumentou o projeto e transformou "acho que está bom" em número: linter, testes, Lighthouse e monitoramento de erros. Hoje a trilha fecha com a ferramenta mais nova da caixa e, de longe, a mais mal usada — o assistente de inteligência artificial. Ele acelera muito quem já sabe o que está fazendo e atrapalha silenciosamente quem não sabe, porque escreve com a mesma confiança um trecho correto e um trecho inventado; a diferença entre os dois casos não está na ferramenta, e sim no que você faz depois que a resposta aparece na tela. É esse "depois" que você vai aprender aqui, usando exatamente aquelas ferramentas como rede de verificação para tudo o que o assistente sugerir — porque a única maneira honesta de aceitar código de IA é ter como provar que ele funciona. Ao fim deste capítulo, o ciclo completo (escrever, versionar, publicar, medir, revisar) fecha.
Um assistente de código é um modelo de linguagem: dado um texto, ele calcula qual pedaço de texto tem maior chance de vir a seguir, e repete isso até formar a resposta. Ele foi treinado em uma quantidade enorme de código público, documentação, fóruns e livros. O efeito prático é impressionante — ele "sabe" a forma de uma rota Express, a cara de um componente Vue, o jeito de uma consulta SQL — porque essas formas se repetem milhões de vezes no material de treino.
O que não existe nesse processo é uma etapa de conferência. O modelo não executa o código que escreve, não consulta a documentação da versão que você usa e não tem um banco de dados de verdades para checar. Ele produz o texto mais plausível. Quando o plausível coincide com o correto — o caso comum em tarefas rotineiras — você ganha tempo. Quando não coincide, você recebe um erro escrito com a mesma segurança de uma resposta certa. Esse é o ponto central deste capítulo, e vale escrever em letras grandes:
⚠️ Atenção
A confiança do texto não carrega nenhuma informação sobre a correção do texto. Um assistente não diz "acho que" com menos frequência quando está errado. Trate toda resposta como um palpite muito bem informado de um colega que nunca rodou o seu projeto.
Três consequências práticas disso, que explicam quase todos os problemas do capítulo:
Ele responde pela média do que existe na internet. Se 90 % do código público de Express ainda é da versão 4, a resposta média será da versão 4 — mesmo você usando a 5.
Ele não sabe o que não está no prompt. O nome das suas tabelas, a estrutura das suas pastas, o que você já tentou: nada disso existe para ele se você não colar.
Ele erra mais no específico do que no geral. "Explique o que é um middleware" tem chance altíssima de sair correto. "Qual o nome exato da opção do Vuetify 4 para o tema padrão" tem chance bem menor.
🧠 Você sabia?
O termo técnico para uma resposta inventada com aparência de verdade é alucinação. Ele já saiu do jargão acadêmico e virou vetor de ataque: pesquisadores mostraram que modelos inventam nomes de pacotes npm/PyPI de forma repetível — o mesmo nome falso aparece várias vezes para prompts parecidos. Atacantes registram esses nomes inventados e esperam alguém rodar npm install. O apelido do golpe é slopsquatting, primo do typosquatting. É por isso que a regra "todo pacote novo é conferido no npmjs.com antes de instalar" deixou de ser preciosismo.
Padrão altamente repetido, verificável em segundos
Traduzir entre linguagens ou estilos
Muito bom
Tarefa de forma, não de verdade
Escrever README, commit, comentário
Bom, com revisão
Ele só sabe o que você contou do projeto
Lembrar detalhes de uma versão recente
Fraco
A média do treino puxa para a versão antiga
Decidir arquitetura do seu projeto
Fraco
Ele não conhece seus requisitos nem o seu prazo
Afirmar que algo "não existe"
Muito fraco
Ele tende a inventar em vez de admitir lacuna
Contar, medir, garantir desempenho
Muito fraco
Não executa nada; números costumam ser inventados
A leitura correta dessa tabela não é "use só nas linhas boas". É: quanto mais para baixo na tabela, mais cara fica a verificação — e a verificação é sua.
Você conversa em uma janela separada do editor. O assistente enxerga apenas o que você cola. É o formato mais fácil de controlar e o melhor para aprender, justamente porque obriga você a decidir o que é relevante — o ato de recortar o contexto já é metade do diagnóstico.
Bom para: entender um erro, comparar duas abordagens, revisar um arquivo, transformar um rascunho em README, pedir explicação de um conceito com exemplos.
Cuidado: a conversa não conhece o resto do projeto. Se você pedir "adicione paginação" sem colar a rota, ele vai inventar uma rota plausível e você vai colar código que não casa com o seu.
Uma extensão do VS Code sugere a continuação enquanto você digita, em cinza; Tab aceita, Esc descarta. Ele lê os arquivos abertos e o arquivo atual, então acerta muito o estilo do seu projeto: seus nomes em português, sua indentação, seu jeito de tratar erro.
Bom para: a segunda, terceira e quarta rota depois que você escreveu a primeira; testes parecidos entre si; blocos catch; preencher um objeto grande.
Cuidado: é o formato que mais induz a aceitar sem ler, porque a sugestão aparece exatamente onde o seu olho já está. E ele continua o padrão do arquivo, inclusive quando o padrão está errado — se a sua primeira rota concatena SQL, ele vai concatenar nas próximas cinco.
2.3 Agente no editor ou no terminal (Claude Code, Cursor)¶
Aqui o assistente lê e escreve arquivos do projeto, roda comandos e mostra um diff para você aprovar. É o formato mais poderoso e o mais perigoso: ele pode alterar dez arquivos com uma frase sua.
Bom para: refatorações mecânicas em muitos arquivos, criar a estrutura inicial de testes, migrar um padrão repetido, investigar "onde está a função que faz X" em um projeto grande.
Cuidado: só use com o Git limpo e em uma branch (git switch -c ia/refatora-inscricoes). O diff é o seu contrato — se você não consegue ler o diff, não aceite. E jamais deixe um agente rodar comandos destrutivos ou de deploy sem olhar.
Formato
O que ele enxerga
Risco principal
Chat
Só o que você colar
Resposta genérica que não casa com o projeto
Autocompletar
Arquivos abertos no editor
Aceitar por reflexo, sem ler
Agente
O repositório inteiro, se autorizado
Mudança ampla difícil de auditar
💡 Dica
Com agente, adote a regra do commit-âncora: git add -A && git commit -m "checkpoint antes da IA" antes de começar. Se a refatoração sair errada, git reset --hard HEAD devolve tudo. Sem esse commit você fica refém do "desfazer" do editor.
A diferença entre uma resposta inútil e uma resposta cirúrgica quase nunca está no modelo. Está em seis elementos. Um prompt profissional tem todos.
Objetivo em uma frase — o que você quer que aconteça, não como fazer.
Código real — o trecho mínimo que reproduz o problema, com o caminho do arquivo.
Versões — Node, framework, biblioteca. É o elemento mais esquecido e o que mais evita alucinação.
Erro literal — copiado e colado do terminal ou do console, inteiro, com a pilha.
O que você já tentou — evita que ele repita o caminho que você já eliminou.
Formato esperado — "responda com a causa em duas frases e depois o arquivo corrigido inteiro".
Compare. O prompt ruim, que todo mundo escreve na primeira semana:
Texto
minha api não ta funcionando, da erro quando eu mando o post. me ajuda
E o prompt bom, sobre o mesmo problema:
Texto
Objetivo: descobrir por que meu POST /api/inscricoes responde 500.
Stack: Node 22 LTS, Express 5.1, mysql2 3 (mysql2/promise, createPool), ESM.
Arquivo unieventos-api/src/rotas/inscricoes.js:
import { Router } from 'express';
import { pool } from '../db/pool.js';
export const rotasInscricoes = Router();
rotasInscricoes.post('/', async (req, res) => {
const { eventoId, nome, email } = req.body;
const [resultado] = await pool.query(
'INSERT INTO inscricoes (evento_id, nome, email) VALUES (?, ?, ?)',
[eventoId, nome, email]
);
res.json({ id: resultado.insertId });
});
Erro literal no terminal:
TypeError: Cannot destructure property 'eventoId' of 'req.body' as it is undefined.
at /home/ana/unieventos-api/src/rotas/inscricoes.js:7:11
Requisição feita com: curl -X POST http://localhost:3000/api/inscricoes
-H "Content-Type: application/json" -d '{"eventoId":1,"nome":"Ana","email":"ana@exemplo.com"}'
Já tentei: reiniciar o servidor e conferir que a tabela existe.
Formato da resposta: causa em duas frases, depois a linha exata que falta e onde ela entra.
O segundo prompt tem uma resposta praticamente inevitável e correta: falta app.use(express.json()) antes das rotas, e no Express 5 req.body é undefined (não {}) quando nada foi analisado. O primeiro prompt tem mil respostas possíveis, e você vai testar seis delas antes de chegar à certa.
🔬 Investigue
Faça o teste dos dois prompts acima com o seu próprio bug, agora. Abra duas conversas novas. Na primeira, escreva a versão preguiçosa. Na segunda, os seis elementos. Cronometre quanto tempo passa até você ter uma correção que funciona em cada caso. Anote os dois tempos — eles vão para o IA.md do Passo a passo. É comum a diferença ser de uma ordem de grandeza.
"Se faltar informação para responder, pergunte antes de supor." Reduz drasticamente a invenção de nomes de tabela e de rota.
"Cite a página da documentação oficial que sustenta cada afirmação." Ele pode errar o link, mas passa a ancorar a resposta em algo verificável — e um link inventado é fácil de flagrar: você clica.
"Aponte problemas; não elogie o código." Sem isso, muitos assistentes começam por "ótimo código!" e amaciam a crítica. Você não quer um elogio; quer a lista de defeitos.
💡 Dica
Peça sempre o arquivo inteiro corrigido, não "as linhas que mudam". Resposta em pedaços solta reticências e comentários do tipo "restante igual", e é aí que você cola um arquivo quebrado. Arquivo inteiro você compara com git diff e enxerga tudo.
É o melhor uso, disparado. A mensagem literal do erro é um índice quase perfeito: se aquele erro é comum, o assistente já viu centenas de discussões sobre ele. Cole a mensagem inteira, com a pilha, e diga a versão.
O ganho não é só a correção — é a explicação. Peça sempre: "explique por que esse erro acontece, em três frases, antes de corrigir". Em um semestre isso constrói um repertório de diagnóstico que nenhuma correção copiada constrói.
Aqui a IA rende porque a tarefa é procurar padrões ruins, e padrões ruins são exatamente o que se repete no material de treino. Um exemplo real, tirado do que aparece em quase todo projeto autoral na primeira versão:
JavaScript
// unieventos-api/src/rotas/inscricoes.js — versão antes da revisãoimport{Router}from'express';import{pool}from'../db/pool.js';exportconstrotasInscricoes=Router();rotasInscricoes.post('/',async(req,res)=>{const{eventoId,nome,email}=req.body;const[resultado]=awaitpool.query("INSERT INTO inscricoes (evento_id, nome, email) VALUES ("+eventoId+", '"+nome+"', '"+email+"')");res.json({id:resultado.insertId,nome,email});});
O prompt de revisão que funciona:
Texto
Revise esta rota Express 5 procurando problemas de segurança, validação e
tratamento de erro. Não elogie. Para cada problema, dê: (1) o trecho exato,
(2) por que é problema, (3) como reproduzir o problema com um curl,
(4) a correção. Ordene do mais grave para o menos grave.
Stack: Node 22, Express 5.1, mysql2 3 com createPool, MySQL 8.
[cole aqui o código acima]
Uma resposta típica traz cinco achados. Você não aceita nenhum antes de checar — a coluna "confirmei como?" é a parte que importa:
Achado apontado pela IA
Gravidade
Confirmei como?
SQL montado por concatenação: injeção
Alta
curl com nome contendo apóstrofo derruba a consulta
Nenhuma validação: aceita nome vazio ou ausente
Alta
curl sem nome grava a string undefined no banco
Responde 200 na criação, deveria ser 201
Média
curl -i mostra HTTP/1.1 200 OK
Devolve o e-mail na resposta (dado pessoal)
Média
Leitura do corpo da resposta
Não confere se o evento existe
Média
curl com eventoId 9999 grava inscrição órfã
A versão corrigida, que você escreve entendendo cada linha:
JavaScript
// unieventos-api/src/rotas/inscricoes.js — versão revisadaimport{Router}from'express';import{pool}from'../db/pool.js';exportconstrotasInscricoes=Router();rotasInscricoes.post('/',async(req,res)=>{const{eventoId,nome,email}=req.body??{};if(!Number.isInteger(eventoId)||eventoId<=0){returnres.status(400).json({erro:'Informe eventoId como número inteiro positivo.'});}if(typeofnome!=='string'||nome.trim().length<3){returnres.status(400).json({erro:'Informe nome com ao menos 3 caracteres.'});}if(typeofemail!=='string'||!email.includes('@')){returnres.status(400).json({erro:'Informe um e-mail válido.'});}const[eventos]=awaitpool.query('SELECT id FROM eventos WHERE id = ?',[eventoId]);if(eventos.length===0){returnres.status(404).json({erro:'Evento não encontrado.'});}const[resultado]=awaitpool.query('INSERT INTO inscricoes (evento_id, nome, email) VALUES (?, ?, ?)',[eventoId,nome.trim(),email.trim().toLowerCase()]);res.status(201).json({id:resultado.insertId,eventoId,nome:nome.trim()});});
Repare que os ? do mysql2 não são "escapar aspas": o driver envia valor e comando separados, então nenhum conteúdo digitado pelo usuário vira instrução SQL. Essa é a frase que você precisa saber dizer se alguém perguntar — numa revisão de código, numa entrevista técnica ou para si mesmo.
Escrever o primeiro teste é chato; escrever o décimo é mecânico. A IA é ótima nos dois casos, desde que você faça três coisas depois:
Rodar. Teste gerado que nunca rodou é decoração.
Quebrar de propósito. Mude a regra no código (length < 3 para length < 0) e confirme que o teste falha. Teste que passa com o código quebrado não testa nada — é o erro número um de suíte gerada por IA, porque o modelo tende a escrever asserções frouxas.
Acrescentar o caso que ele não pensou. Ele cobre o caminho feliz e o campo faltando; raramente cobre o seu caso de negócio (evento lotado, inscrição duplicada, e-mail já cadastrado).
Prompt que produz teste utilizável, no formato do runner embutido apresentado no Capítulo 10 §3.1 — a alternativa sem dependências, apropriada aqui porque o pedido é justamente não instalar nada:
Texto
Gere testes com node:test (Node 22, ESM) para a rota POST /api/inscricoes abaixo.
O app é exportado nomeado em src/app.js (export const app) e não chama listen:
suba-o no teste com app.listen(0) e faça as requisições com fetch. Cubra: criação
válida (201), nome ausente (400), nome com 2 caracteres (400) e evento inexistente
(404). Feche o servidor e o pool no after. Não use nenhuma biblioteca externa.
[cole a rota revisada]
E o resultado, depois de você conferir e completar:
JavaScript
// unieventos-api/tests/inscricoes.test.jsimport{test,before,after}from'node:test';importassertfrom'node:assert/strict';import{app}from'../src/app.js';import{pool}from'../src/db/pool.js';letservidor;letbase;before(async()=>{servidor=app.listen(0);awaitnewPromise((pronto)=>servidor.once('listening',pronto));base=`http://127.0.0.1:${servidor.address().port}`;awaitpool.query('DELETE FROM inscricoes');awaitpool.query('INSERT INTO eventos (id, titulo, vagas) VALUES (1, ?, 40) '+'ON DUPLICATE KEY UPDATE titulo = VALUES(titulo)',['Semana Acadêmica']);});after(async()=>{servidor.close();awaitpool.end();});asyncfunctioninscrever(corpo){returnfetch(`${base}/api/inscricoes`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(corpo)});}test('cria a inscrição e responde 201',async()=>{constresposta=awaitinscrever({eventoId:1,nome:'Ana Souza',email:'ana@exemplo.com'});assert.equal(resposta.status,201);constcorpo=awaitresposta.json();assert.ok(Number.isInteger(corpo.id));assert.equal(corpo.nome,'Ana Souza');});test('recusa inscrição sem nome',async()=>{constresposta=awaitinscrever({eventoId:1,email:'ana@exemplo.com'});assert.equal(resposta.status,400);constcorpo=awaitresposta.json();assert.match(corpo.erro,/nome/i);});test('recusa nome com menos de 3 caracteres',async()=>{constresposta=awaitinscrever({eventoId:1,nome:'An',email:'ana@exemplo.com'});assert.equal(resposta.status,400);});test('recusa evento inexistente',async()=>{constresposta=awaitinscrever({eventoId:9999,nome:'Ana Souza',email:'ana@exemplo.com'});assert.equal(resposta.status,404);});
Escreva uma mensagem de commit no padrão Conventional Commits, em português,
com título de no máximo 72 caracteres e um corpo de até 4 linhas explicando o
porquê (não o quê). Baseie-se apenas neste diff:
[cole o conteúdo de /tmp/mudanca.diff]
Resultado típico, muito melhor do que o "ajustes" que você escreveria com pressa:
Texto
fix(inscricoes): usa parâmetros na consulta e valida a entrada
A rota montava o INSERT por concatenação, permitindo injeção de SQL com
qualquer apóstrofo no nome. Passa a usar placeholders do mysql2 e a
recusar com 400 corpo sem eventoId, nome curto ou e-mail sem arroba.
Inscrição em evento inexistente agora responde 404.
Para o README, o segredo é o mesmo: alimente com fatos do repositório, não com adjetivos.
Escreva o README.md do meu projeto a partir dos fatos abaixo, em português,
com estas seções: o que é, tecnologias, como rodar localmente, variáveis de
ambiente, rotas da API, como rodar os testes, como publicar. Não invente
funcionalidade que não esteja nos fatos. Onde faltar informação, escreva a
seção com uma pergunta objetiva para mim entre colchetes.
[cole a saída dos comandos acima]
A instrução final é a mais importante: sem ela, o assistente preenche as lacunas com invenção simpática ("suporta autenticação JWT", que você nunca implementou). Com ela, você recebe uma lista de perguntas — que é exatamente o que um bom README precisa que você responda.
Use o assistente como um professor particular impaciente, não como um manual. Três pedidos que funcionam:
"Explique createPool do mysql2 comparando com abrir e fechar uma conexão a cada requisição, com uma analogia."
"Me faça cinco perguntas de múltipla escolha sobre middlewares do Express 5 e só depois mostre as respostas."
"Escreva o menor exemplo executável possível de um store Pinia com uma ação assíncrona, e diga qual linha eu deveria mudar primeiro se a lista vier vazia."
Depois de entender, confirme na documentação oficial. Esse par — explicação rápida da IA, confirmação lenta na doc — é mais eficiente do que só ler a doc e infinitamente mais confiável do que só perguntar.
🧠 Você sabia?
Um estudo controlado da Universidade Stanford comparou pessoas resolvendo tarefas de programação com e sem assistente de IA. O grupo com assistente escreveu, em média, código menos seguro — e, ao mesmo tempo, declarou mais confiança de que o código estava seguro. Não é que a ferramenta seja ruim: é que ela desliga a desconfiança de quem a usa. O antídoto é procedimento, não força de vontade: é por isso que a §7 deste capítulo é um checklist e não um conselho.
O modo de falha mais frequente na nossa stack, porque quase toda biblioteca desta trilha teve uma virada de versão recente e o material de treino é dominado pela versão antiga. Os quatro casos que você vai encontrar com mais frequência:
Você usa
A IA costuma responder
Como flagrar
Express 5
body-parser, app.del(), res.json(obj, 201)
express.json() é nativo; app.del is not a function
Firebase 12 modular
firebase.auth().signInWith…
firebase.auth is not a function; só existe import { getAuth }
Vuetify 4
Props align/justify em v-row; supõe tema claro por padrão
Na 4 o alinhamento é classe utilitária e o tema padrão é system
swagger-jsdoc 6
Chave swaggerDefinition
A 6.x usa definition; a doc gerada sai vazia
Vale para o Supabase também: código gerado costuma esquecer que uma tabela com RLS habilitado e sem policy devolve data: [] sem nenhum erro. O assistente entrega uma consulta correta, o front mostra "nenhum evento" e você passa duas horas caçando um bug que não está no JavaScript.
O antídoto é barato e cabe no prompt: diga a versão. "Express 5", "Vuetify 4", "firebase 12 modular". Melhora a resposta na hora — e, quando não melhora, você tem a linha exata para conferir.
🔬 Investigue
Peça agora ao seu assistente: "Como faço para o Vuetify 4 abrir sempre no tema claro?" e, em outra conversa, "Como faço para o Vuetify abrir sempre no tema claro?". Compare. Depois abra a documentação oficial do Vuetify e confira qual das duas respostas casa com a opção defaultTheme de createVuetify. Guarde as duas respostas: elas são o material do desafio ⭐⭐.
Se o pacote não existe, o npm responde com clareza:
Texto
npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/pacote-que-a-ia-sugeriu
npm error 404 'pacote-que-a-ia-sugeriu@*' is not in this registry.
Se existe, olhe três números antes de instalar: downloads por semana, data da última publicação (no site do npm) e número de dependências. Um pacote com 12 downloads semanais para uma tarefa comum é bandeira vermelha — pode ser o nome que o modelo inventou e alguém registrou. E rode npm audit depois de qualquer instalação nova.
Este é o mais perigoso porque funciona. O código roda, o teste manual passa, e o problema só aparece quando alguém mal-intencionado chega. Os cinco que mais aparecem em projetos autorais gerados com ajuda de IA:
cors() sem opção nenhuma, liberando qualquer origem, em uma API com sessão.
Consulta SQL montada por concatenação, como na §4.2.
Chave service_role do Supabase no front-end "porque assim funciona sem configurar policy".
Senha ou token aparecendo no console.log de depuração que ficou no código.
Verificação de autorização feita no front (esconder o botão) e não na rota da API.
Nenhum desses é pegado por um teste de caminho feliz. Todos são pegados por uma pergunta explícita no prompt de revisão — "procure problemas de segurança e diga como reproduzir cada um" — seguida da reprodução com curl.
Tudo que você cola em um assistente sai do seu computador. Dependendo do serviço e do plano, o conteúdo pode ser guardado, revisado por pessoas ou usado para treinar modelos futuros. Trate a caixa de texto como um post público.
Nunca cole
Por quê
O que fazer no lugar
.env, senhas, DATABASE_URL
Vaza credencial de produção
Cole só os nomes das variáveis
Chave de API, token, service_role
Uso indevido imediato da sua conta
Substitua por CHAVE_AQUI
Dados pessoais reais (CPF, telefone, e-mail de gente de verdade)
LGPD: você é o controlador desses dados
Gere dados fictícios
Código sob contrato do estágio
Pode violar o contrato do seu trabalho
Reescreva um exemplo mínimo equivalente
Prova, processo seletivo ou material sob sigilo (seu ou de outra pessoa)
Confidencialidade não é sua para compartilhar
Nada substitui; simplesmente não cole
Como redigir um trecho antes de colar, sem perder o contexto:
JavaScript
// unieventos-api/src/db/pool.js — versão segura para colar em um promptimportmysqlfrom'mysql2/promise';exportconstpool=mysql.createPool({host:process.env.DB_HOST,// valor real: um hostname da nuvemuser:process.env.DB_USER,// valor real: um usuário da aplicaçãopassword:process.env.DB_PASSWORD,// valor real: senha forte, não colada aquidatabase:process.env.DB_NAME,waitForConnections:true,connectionLimit:10});
Repare: o código continua completo e o assistente entende tudo o que precisa. O que ele não recebe é o valor.
⚠️ Atenção
Se um segredo escapar — para o chat, para um commit ou para um print compartilhado com outras pessoas — a única correção é rotacionar: gere uma chave nova no painel do serviço, invalide a antiga, atualize o .env local, os secrets do GitHub Actions e o servidor. Apagar a mensagem não resolve, e reverter o commit não resolve: o valor continua no histórico do Git. O GitHub ajuda com a proteção de push, que recusa o envio com uma mensagem parecida com esta:
7. Verificar é o trabalho: o protocolo dos cinco passos¶
Aceitar uma sugestão de IA sem esse ciclo é o equivalente a fazer deploy sem testar. São cinco passos, sempre na mesma ordem, e o quinto é o mais importante.
Reproduza o problema antes. Se você não sabe demonstrar o defeito, não vai saber demonstrar a correção. Um curl, um teste que falha, uma linha no log.
Leia linha a linha. Toda linha que você não entende é uma pergunta para o assistente ("por que essa linha existe?"), não uma linha para aceitar.
Confira o que é específico na fonte oficial. Nome de opção, assinatura de função, comportamento de versão: cinco minutos na documentação valem cinco horas de depuração. Assuma que o específico está errado até provar o contrário.
Rode.npm run lint, npm test, curl na rota, clique na tela. Depois quebre de propósito e confirme que o teste falha.
Explique em voz alta, sem olhar. Feche o editor e conte para um colega — ou para a parede — o que a mudança faz e por quê. Se travar, você não entendeu; volte ao passo 2.
📌 Vale gravar
O passo 5 não é uma metáfora. A qualquer momento — um colega perguntando, uma entrevista técnica, ou você mesmo relendo o código em seis meses — alguém pode apontar uma linha e pedir a explicação. Você pode ter usado IA para escrevê-la, mas precisa saber dizer o que ela faz, por que está ali e o que aconteceria se fosse removida. Saber explicar o próprio código é o que separa quem aprendeu de quem colou.
Existe um efeito bem documentado no aprendizado: o esforço de recuperar uma informação da memória, e o de errar antes de acertar, são exatamente o que fixa o conhecimento. Ler uma explicação clara dá a sensação de aprendizado sem o aprendizado — a chamada fluência ilusória. Um assistente é uma máquina industrial de produzir fluência ilusória: tudo fica claro, tudo parece óbvio, e três dias depois você não consegue escrever a mesma rota sozinho.
Isso não é argumento para não usar. É argumento para usar em uma ordem específica:
Regra dos 15 minutos. Tente sozinho por 15 minutos antes de perguntar. Se resolver, você ganhou a habilidade. Se não, você chega ao prompt com um diagnóstico — e o prompt fica muito melhor.
Regra do rascunho. Escreva a sua versão primeiro, ainda que feia, e só depois peça revisão. Comparar a sua solução com a dele ensina; receber a dele pronta, não.
Regra do fechamento. Ao fim de cada tarefa em que você usou IA, refaça de memória a parte central, com o editor fechado. Leva dois minutos e converte a leitura em prática.
Zona sem IA. Escolha uma área para aprender na mão neste semestre — para muita gente, é CSS de layout ou a lógica assíncrona. Sem autocompletar, sem chat.
🧠 Você sabia?
Quando o ChatGPT surgiu, o Stack Overflow proibiu respostas geradas por IA no site. O motivo declarado não foi a qualidade média — era razoável — e sim a taxa de acerto combinada com a facilidade de produção: respostas erradas passaram a chegar mais rápido do que voluntários conseguiam revisar. É o mesmo problema em escala pessoal: a IA gera código mais rápido do que você revisa. Quem não impõe um limite de revisão acumula dívida técnica em velocidade recorde.
Não existe fiscal aqui, e este material não tem nota. O motivo para ter uma política pessoal de uso de IA não é evitar punição — é que só você paga o preço de aprender de mentirinha. Um projeto que "funciona" mas que você não sabe explicar não te prepara para a próxima vaga, para a próxima entrevista técnica, ou para o próximo bug que a IA não vai resolver sozinha.
A regra que resume tudo isto: use IA para explicar, não para entregar o que você não sabe explicar. Pedir para o assistente destrinchar um erro, revisar um trecho, sugerir testes, ensinar uma API nova — isso é estudo, e é o uso incentivado ao longo deste capítulo. Colar uma resposta pronta num projeto e seguir em frente sem entender por que ela funciona não é estudo: é adiar o problema para o momento em que você mais precisar da habilidade que pulou.
Situação
Ajuda a aprender?
Por quê
Pedir explicação de um erro e corrigir entendendo
Sim
O melhor uso; recomendado
Gerar testes, revisar e completar com casos seus
Sim
Verifique quebrando de propósito
Gerar o README a partir de fatos do repositório
Sim
Revise; ele inventa funcionalidade
Colar código do assistente sem entender
Não
Na próxima vez que precisar mexer ali, você vai travar
Pedir para alguém (ou uma IA) terminar o seu projeto autoral por você
Não
O projeto para de ser seu; a habilidade que ele deveria treinar não aparece
Colar .env ou dado pessoal real no chat
Não
Incidente de segurança; rotacione a chave
Duas práticas que fazem essa política funcionar de verdade, sem precisar de ninguém cobrando:
Registre o uso. Um arquivo IA.md na raiz do projeto (modelo no Passo a passo) dizendo em que partes você usou assistente, o que aceitou e o que recusou — guarde-o no seu próprio repositório. Não é para prestar contas a ninguém: é um espelho. Escrever obriga você a notar o que realmente aconteceu, e reler daqui a um mês mostra se você está aprendendo ou só acelerando a entrega.
Teste-se de vez em quando. Feche o editor e explique em voz alta — para um colega, ou para a parede — o que uma parte do seu código faz e por que está ali. Se travar, você ainda não entendeu; volte ao passo 2 do protocolo da §7. Ninguém vai te arguir por isso, mas o dia em que alguém perguntar — numa entrevista, numa vaga, numa dúvida sua mesmo — é o dia em que essa prática se paga.
💡 Dica
Ajudar um colega a entender um problema é ótimo — é assim que se aprende em grupo. Entregar o arquivo pronto para ele é outra coisa: você tira dele exatamente o exercício que o projeto deveria proporcionar. Se dois projetos autorais chegam com a mesma estrutura de pastas, os mesmos nomes de variável e os mesmos comentários, vale perguntar a si mesmo quem realmente escreveu aquilo — e se alguém aprendeu o que deveria.
🚀 Passo a passo — Revisão assistida do projeto autoral, com tudo verificado¶
Ao final destes passos o seu projeto autoral terá três problemas reais corrigidos (cada um com o commit que prova a correção), uma suíte de testes que falha quando o código quebra, um README.md que descreve o que existe de verdade e um IA.md registrando como a IA foi usada.
Está no Nível 2? Aplique o mesmo passo na cafe-cerrado-api: o projeto autoral pode ser o Café Cerrado estendido, e todo o roteiro — contexto, revisão, teste, commit — vale sem alteração.
O git status precisa estar limpo: a partir daqui, tudo que aparecer em git diff é mudança sua ou proposta da IA, e você quer conseguir distinguir. Se npm run lint ou npm test já falham, corrija antes — você não vai conseguir avaliar sugestão nenhuma com a base quebrada.
Escolha o recorte: uma rota da API que grava dados e um componente do front que consome essa rota. Não peça revisão do projeto inteiro; resposta genérica é o que se ganha com prompt genérico.
Monte o prompt com os seis elementos da §3. Modelo, adapte aos seus arquivos:
Texto
Você é um revisor de código experiente em Node e Vue. Não elogie o código.
Objetivo: encontrar problemas reais nesta rota e neste componente.
Stack: Node 22 LTS, Express 5.1, mysql2 3 (mysql2/promise, createPool),
Vue 3.5 com <script setup>, Vuetify 4, Pinia 4, axios 1.19 com axios.create.
Para cada problema encontrado, responda nesta ordem:
1. arquivo e trecho exato
2. por que é problema (uma frase)
3. como eu reproduzo o problema (comando curl ou passo na tela)
4. a correção, com o arquivo inteiro corrigido
5. o link da documentação oficial que sustenta a correção
Ordene do mais grave para o menos grave. Se faltar informação, pergunte
antes de supor.
Arquivo 1 — src/rotas/<sua-rota>.js:
[cole o arquivo inteiro]
Arquivo 2 — src/components/<SeuComponente>.vue:
[cole o arquivo inteiro]
Passo 3 — Triagem: transformar a resposta em lista verificável¶
Crie ACHADOS.md na raiz e transcreva cada achado, um por linha, antes de mexer em qualquer código. Escrever a lista força você a separar o que é problema do que é opinião de estilo.
Markdown
# Achados da revisão assistida
| # | Achado (em uma linha) | Situação |
|---|---|---|
| 1 | INSERT montado por concatenação: injeção de SQL | a verificar |
| 2 | Nenhuma validação do corpo: grava "undefined" | a verificar |
| 3 | Componente não trata erro do axios: tela em branco | a verificar |
| 4 | Sugere instalar express-async-errors | a verificar |
| 5 | Sugere usar align="center" em v-row | a verificar |
Esta é a etapa que separa este capítulo de "pedir código para a IA". Para cada linha da tabela, aplique o protocolo da §7 e mude a coluna Situação para confirmado, falso ou estilo:
Terminal
# achado 1: reproduzir a injeção antes de corrigir
curl-i-XPOSThttp://localhost:3000/api/inscricoes\-H"Content-Type: application/json"\-d'{"eventoId":1,"nome":"O'\''Brien","email":"teste@exemplo.com"}'# achado 2: reproduzir a falta de validação
curl-i-XPOSThttp://localhost:3000/api/inscricoes\-H"Content-Type: application/json"-d'{"eventoId":1}'# achado 4: conferir se o pacote é mesmo necessário nesta versão
npmviewexpress-async-errorsversion
node-e"console.log(require('express/package.json').version)"
No exemplo acima, o achado 4 é falso: o Express 5 já captura erros de handlers async sozinho, então o pacote é dispensável. O achado 5 também é falso para nós: no Vuetify 4 o alinhamento de v-row é feito por classes utilitárias. Marcar os dois como falsos e escrever o motivo no ACHADOS.md vale tanto quanto corrigir os verdadeiros — é a prova de que você revisou o revisor.
Um commit por problema, cada um com a mensagem escrita a partir do diff (§4.4):
Terminal
gitaddsrc/rotas/inscricoes.js
gitcommit-m"fix(inscricoes): usa placeholders do mysql2 na consulta"
npmtest
gitaddsrc/rotas/inscricoes.js
gitcommit-m"fix(inscricoes): valida corpo e responde 400 com mensagem clara"
npmtest
gitaddsrc/components/FormularioInscricao.vue
gitcommit-m"fix(inscricao): mostra mensagem de erro quando a API falha"
npmrunlint
Depois de cada correção, repita o curl do Passo 4. A mesma requisição que reproduzia o problema agora precisa responder 400, 404 ou 201 — nunca 500.
Peça a suíte com o prompt da §4.3, rode e então faça o teste de mutação, que é o passo que quase ninguém faz:
Terminal
npmtest
Agora quebre o código de propósito, em três lugares diferentes, e rode de novo:
Terminal
# 1. afrouxe a validação: troque length < 3 por length < 0 e rode
npmtest# 2. troque res.status(201) por res.status(200) e rode
npmtest# 3. remova a checagem de evento inexistente e rode
npmtest
Cada mutação precisa fazer pelo menos um teste falhar. Se alguma passar, o teste correspondente é decorativo: aperte a asserção (compare o corpo, não só o status) e repita. Desfaça as três mutações no fim (git checkout -- . se você não commitou nada) e confirme que npm test volta a passar inteiro.
Responda às perguntas entre colchetes que vierem na resposta, apague as que não se aplicam e confira uma a uma as instruções de execução: rode o seu próprio README do zero, em um clone novo, seguindo apenas o que está escrito.
# Uso de IA neste projeto
Assistentes usados: [nome do chat] para revisão e testes; [nome] no editor
para autocompletar rotas repetidas.
## Onde usei-Revisão da rota POST /api/inscricoes e do FormularioInscricao.vue.
-Geração da primeira versão de tests/inscricoes.test.js.
-Primeira versão deste README.
## O que aceitei (e verifiquei)-Uso de placeholders no mysql2: reproduzi a injeção com curl antes e depois.
-Validação do corpo com 400: coberta por dois testes.
-Tratamento de erro do axios no componente: testado com a API desligada.
## O que recusei-Instalar express-async-errors: o Express 5 já captura erro em handler async.
-Usar align="center" em v-row: no Vuetify 4 isso é classe utilitária.
-Sugestão de guardar a chave do Supabase no front: seria vazamento.
## O que escrevi sem IA-Toda a modelagem do banco e as regras de negócio do projeto.
-O componente de listagem e o filtro de busca.
Comite tudo e abra o pull request:
Terminal
gitaddACHADOS.mdIA.mdREADME.md
gitcommit-m"docs: registra revisão assistida, achados e uso de IA"
gitpush-uoriginrevisao-assistida
ghprcreate--title"Revisão assistida do projeto autoral"\--body"Três problemas confirmados e corrigidos; testes endurecidos; README e IA.md."
gh pr view --web mostra o pull request com pelo menos quatro commits, um por correção mais o de documentação.
ACHADOS.md tem toda linha com situação confirmado, falso ou estilo — nenhuma como a verificar — e os falsos têm o motivo escrito.
npm test passa; e, com qualquer uma das três mutações do Passo 6, falha.
Os curl que reproduziam os problemas agora respondem 201, 400 e 404, conforme o caso.
Você consegue explicar, sem olhar o editor, o que cada uma das três correções faz.
Resultado esperado: um pull request em que dá para ver a IA sendo usada como revisora e você sendo o autor — problemas reproduzidos, correções testadas e duas sugestões erradas recusadas com justificativa.
A1. Explique em duas frases por que um assistente pode descrever com total segurança uma função que não existe. Depois diga qual dos seis elementos do prompt (§3) mais reduz esse risco e por quê.
A2. Classifique cada prompt como bom ou ruim e diga qual elemento falta em cada ruim: (a) "corrige meu css"; (b) "No Vue 3.5 com script setup e Vuetify 4, meu v-data-table não mostra os itens; segue o componente inteiro e o JSON que a API devolve"; (c) "melhor jeito de fazer login"; (d) "Node 22, Express 5: TypeError: app.del is not a function na linha 14 de src/rotas/eventos.js, arquivo colado abaixo; o que mudou?".
A3. Um assistente respondeu com firebase.auth().signInWithEmailAndPassword(email, senha). Diga o que está errado, qual seria a forma correta na versão que usamos e em que página da documentação oficial você confirmaria isso.
A4. Destes itens, quais podem ir para um prompt e quais não podem — justifique cada um: o trecho de uma rota; o conteúdo do .env; a mensagem de erro do terminal; a DATABASE_URL de produção; o .env.example; a lista de nomes reais dos inscritos no seu evento de teste.
A5. A IA sugeriu npm install express-async-errors para o seu projeto Express 5 e npm install body-parser para ler JSON. Diga se cada uma é necessária, por quê, e escreva o comando que confirma a sua resposta sem instalar nada.
B1. Pegue um bug real do seu projeto e escreva dois prompts sobre ele: um preguiçoso (uma linha) e um completo (os seis elementos da §3). Use conversas separadas e registre quanto tempo passou até você ter uma correção que funciona em cada caso.
Resultado esperado: um trecho no seu IA.md com os dois prompts, os dois tempos e uma frase dizendo qual elemento fez mais diferença.
Dica
Cronometre de verdade, com o relógio do celular. O tempo do prompt completo inclui o tempo de escrevê-lo — é justamente esse o custo que você quer medir, e ele costuma se pagar já na primeira rodada.
B2. Caça à alucinação. Faça cinco perguntas específicas de versão ao assistente (uma de Express 5, uma de Vuetify 4, uma de Firebase modular, uma de Supabase com RLS, uma de swagger-jsdoc) e confira cada resposta na documentação oficial.
Resultado esperado: uma tabela de cinco linhas com pergunta, resposta da IA e veredito (confere ou errado, com o link da doc). É normal aparecerem duas ou três erradas.
Dica
Perguntas que rendem: "como capturo erro de handler async no Express 5?", "qual a opção do createVuetify para o tema padrão?", "como faço login por e-mail no firebase 12?", "por que meu select do Supabase devolve array vazio sem erro?", "qual a chave de configuração do swagger-jsdoc 6?".
B3. Peça ao assistente testes para uma função de regra de negócio sua (cálculo de vagas restantes, validação de inscrição, formatação). Rode, depois quebre a função de propósito em dois lugares e rode de novo.
Resultado esperado: cada mutação derruba pelo menos um teste. Se alguma mutação passa, você aperta a asserção e mostra o antes e o depois do arquivo de teste.
Dica
Asserção frouxa típica: assert.ok(resultado) — verdadeira para qualquer número diferente de zero. Troque por assert.equal(resultado, 12). Outra: verificar só o status HTTP e ignorar o corpo da resposta.
B4. Peça uma revisão só de segurança de uma rota sua que grava dados, no formato da §4.2, e reproduza cada achado com curl antes de corrigir.
Resultado esperado: para cada achado confirmado, dois comandos curl no seu ACHADOS.md — o que demonstrava o problema e o mesmo comando depois da correção, com o novo status.
Dica
Use curl -i para ver o status e os cabeçalhos. Para testar injeção, um apóstrofo dentro de um campo de texto já basta: se a resposta for 500 com erro de sintaxe SQL, o problema está confirmado.
C1. O trecho abaixo foi gerado por um assistente e "funciona". Encontre quatro problemas sem usar IA, em no máximo dez minutos. Depois peça a revisão ao assistente e compare: quantos ele achou, quantos você achou, e se ele inventou algum que não existe.
JavaScript
// api/src/app.js — versão gerada por assistente, com problemasimportexpressfrom'express';importcorsfrom'cors';import{createClient}from'@supabase/supabase-js';constapp=express();app.use(cors({origin:'*',credentials:true}));app.use(express.json());constsupabase=createClient('https://abcdefgh.supabase.co','eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.chave-de-servico-colada-aqui');app.post('/api/login',async(req,res)=>{const{email,senha}=req.body;console.log('tentativa de login',email,senha);const{data,error}=awaitsupabase.from('usuarios').select('id, nome, papel').eq('email',email).eq('senha',senha);if(error)returnres.status(500).json({erro:error.message});res.json({usuario:data[0],admin:data[0]?.papel==='admin'});});app.listen(3000);
Dica
Olhe nesta ordem: (1) o que origin: '*' combinado com credentials: true significa e por que os navegadores recusam essa combinação; (2) o que está sendo impresso no log e onde esse log vai parar em produção; (3) que tipo de chave do Supabase está no código e o que alguém faz com ela se ler o repositório; (4) como as senhas estão sendo comparadas e o que deveria estar guardado na coluna; (5) de bônus, o que acontece quando data vem vazio e por que a resposta ainda assim é 200.
Você provavelmente não faz ideia de quanto do seu código vem de sugestão aceita sem leitura. Ninguém faz — e é justamente por isso que a conta assusta quando aparece. Durante uma semana de trabalho no projeto autoral, registre toda interação com assistente e transforme isso em número.
Critérios de pronto
Um arquivo IA.md com no mínimo 10 interações registradas, cada uma com: o que você pediu, se aceitou, e o que fez para verificar.
Uma coluna dizendo se a sugestão foi aceita sem mudança, aceita com mudança ou recusada — e o total de cada categoria.
Pelo menos duas recusas com o motivo técnico escrito (não "não gostei").
Um parágrafo final respondendo: em qual tipo de tarefa a taxa de aceitação foi maior, e por que você acha que foi.
O arquivo está commitado no repositório do projeto autoral.
Pistas
Registre na hora, em três linhas. Reconstituir de memória no fim da semana produz um relato bonito e falso.
Separe por tipo de tarefa: explicar erro, gerar código novo, revisar, documentar. As taxas são bem diferentes entre elas.
"Verifiquei" precisa ser um ato: rodei o teste, abri a doc, reproduzi com curl. Se a verificação foi "pareceu certo", registre exatamente isso — é um dado honesto.
Este é o tipo de registro que fecha bem o Marco final da sua trilha.
Quem consegue medir uma ferramenta para de discutir sobre ela. Monte uma bateria de perguntas cuja resposta certa está na documentação oficial das versões que usamos, aplique em dois assistentes diferentes e publique o placar.
Critérios de pronto
10 perguntas específicas de versão, cobrindo pelo menos quatro tecnologias diferentes desta trilha (Express 5, Vuetify 4, Firebase modular, Supabase com RLS, mysql2, swagger-jsdoc, Vite, Pinia).
Um gabarito escrito antes de perguntar, cada resposta com o link da documentação oficial que a sustenta.
As respostas dos dois assistentes, classificadas em correta, parcialmente correta ou errada, com o critério de classificação declarado.
Um PLACAR.md com a tabela, o total de cada assistente e três frases sobre em que tipo de pergunta os dois erram junto.
A mesma bateria repetida com a versão explícita no prompt (por exemplo, "Express 5" em vez de "Express"), comparando os dois placares.
Pistas
Perguntas boas têm resposta objetiva e curta: nome de opção, assinatura, valor padrão. "Qual a melhor arquitetura" não dá para pontuar.
Escreva o gabarito primeiro. Ler a resposta da IA antes do gabarito contamina o seu julgamento — o efeito é forte e você não percebe acontecendo.
Repare no padrão: erros costumam se concentrar no que mudou de versão recentemente. Isso é uma previsão testável do seu relatório.
Para a comparação final, mude só a menção de versão no prompt. Qualquer outra mudança invalida a comparação.
Toda API construída nesta trilha tem pelo menos uma falha de segurança real. A sua também. Use o assistente como um revisor incansável, mas prove cada coisa: aqui, um problema só conta depois que você escreveu o teste que falha por causa dele.
Critérios de pronto
Uma revisão de segurança de todas as rotas que gravam ou apagam dados, guiada por prompts com o formato da §4.2.
Para cada achado confirmado, um teste automatizado que falha no código atual e passa depois da correção — commitados nessa ordem (teste primeiro, correção depois).
No mínimo três falhas reais corrigidas, entre: injeção de SQL, falta de autorização na rota, CORS permissivo demais, segredo no repositório, dado pessoal exposto na resposta, ausência de validação de entrada.
Um achado refutado com justificativa técnica e o link da documentação: você discordou do revisor e provou por quê.
Um SEGURANCA.md de no máximo uma página com o que foi corrigido, o que ficou de fora e por quê.
npm test e npm run lint passando no CI do Capítulo 09, com a branch protegida.
Pistas
Comece pelas rotas de escrita e pelas que retornam dados de outra pessoa — é onde mora quase toda falha de autorização.
Um teste de autorização precisa de dois usuários: um dono do recurso e um intruso. O intruso precisa receber 403, não 200 com o dado.
Para segredo no histórico do Git, o git log -p -S "eyJ" acha a linha que introduziu uma chave em formato JWT. Achou, rotacione a chave.
Não peça "torne meu código seguro". Peça "liste como um atacante exploraria cada rota, em ordem de facilidade" — a resposta fica muito mais concreta e testável.
Este é o encerramento da trilha, e ele é simples de enunciar: o seu projeto autoral, inteiro, funcionando na internet, com tudo o que os onze capítulos ensinaram — e você capaz de defender cada decisão em dez minutos, sem consultar nada. Não é um exercício novo; é a prova de que os anteriores viraram sistema.
Critérios de pronto
Front publicado em HTTPS com domínio ou subdomínio próprio, e API publicada (Render, VPS com nginx ou contêiner), conversando entre si com CORS restrito à origem do front.
Banco em serviço gerenciado, com backup gerado por você e uma restauração testada de verdade em uma base vazia.
CI no GitHub Actions rodando lint e testes a cada pull request, com a branch main protegida, e deploy automatizado a cada merge.
Lighthouse do site publicado com Performance e Accessibility em 90 ou mais no modo Mobile, e os dois relatórios (antes e depois) no repositório.
Monitoramento ativo: erros no Sentry, /health vigiado por um serviço de uptime e pelo menos um alerta recebido.
README.md que um estranho consegue seguir do clone ao servidor rodando, IA.md declarando o uso de assistentes e SEGURANCA.md com as falhas corrigidas.
Nenhum segredo no repositório: git log -p -S "SUPABASE" | head -50 e a proteção de push do GitHub limpas.
Uma apresentação de 10 minutos, sem slides prontos, navegando pelo repositório e pelo sistema no ar, respondendo a três perguntas sobre linhas escolhidas por outra pessoa — quem te orienta, um colega ou alguém do seu grupo de estudos. Estudando sozinho? Peça para alguém sortear três números de linha do seu git diff (ou use um gerador aleatório) e explique exatamente essas linhas, sem ensaio prévio.
Pistas
Faça na ordem inversa da entrega: comece pelo que quebra em produção (variáveis de ambiente, CORS, banco) e deixe a maquiagem por último.
Teste a restauração do backup em uma base nova e vazia. Backup nunca restaurado não é backup — é um arquivo.
Ensaie a apresentação com outra pessoa escolhendo as linhas por você, não você mesmo. Escolher as próprias linhas esconde exatamente os trechos que você não entende.
Se alguma linha do seu código só existe porque a IA escreveu e você não sabe explicar, apague e reescreva. Uma linha a menos que você entende vale mais do que dez que você defende mal.
Rode o Passo a passo completo em uma rota de escrita e um componente que a consome. Entregue o ACHADOS.md com todos os itens resolvidos (confirmado, falso ou estilo) e nenhum como a verificar.
Corrija os três problemas confirmados, um commit por problema, com mensagem no padrão Conventional Commits gerada a partir do diff e revisada por você.
Gere a suíte de testes da rota, rode o teste de mutação em dois pontos do código e ajuste as asserções frouxas que aparecerem.
Escreva o README.md a partir de fatos do repositório e valide clonando em outra pasta e seguindo apenas o que está escrito.
Escreva o IA.md no formato do Passo 8, incluindo pelo menos duas sugestões recusadas com o motivo técnico.
Critério de pronto: existe um pull request com quatro ou mais commits; npm test passa e falha quando você quebra o código de propósito; ACHADOS.md tem pelo menos um achado marcado como falso com justificativa; o README funciona em um clone limpo; o IA.md está na raiz.
Guarde no seu repositório: commit + push, com o link do pull request na descrição.
npm Docs — npm audit — como ler o relatório de vulnerabilidades antes de aceitar uma dependência sugerida.
OWASP Top 10 — a lista canônica de falhas de aplicação web; use como roteiro do desafio ⭐⭐⭐.
Conventional Commits (pt-BR) — a especificação completa dos tipos feat, fix, docs, chore e do corpo da mensagem.
Node.js — Test runner — a referência de node:test para endurecer os testes que a IA gerar.
Express 5 — Migrating to v5 — a lista oficial do que mudou; é a página que refuta metade das sugestões desatualizadas.
Lei Geral de Proteção de Dados (Lei 13.709) — o texto oficial; leia os artigos 5º e 6º para entender o que é tratamento de dado pessoal, que é o que você faz ao colar um cadastro real em um chat.
Aqui termina a trilha Deploy & Ferramentas. Você começou montando a bancada — terminal, editor, Node — e passou por versionamento, publicação de sites estáticos, domínio e HTTPS, back-end em PaaS, servidor próprio com nginx, contêineres, banco na nuvem, integração contínua, qualidade e observabilidade. Cada capítulo colocou algo real no ar, e o conjunto é o ciclo completo do ofício: escrever, versionar, publicar, medir e revisar.
Este último capítulo fecha o ciclo com a única coisa que nenhuma ferramenta faz por você — responder pelo que está no ar. Um assistente escreve rápido; quem entende do sistema é você. Continue: mantenha os projetos publicados vivos, volte ao Banco de Desafios quando faltar o que fazer, e trate cada linha que você não sabe explicar como uma linha que ainda não é sua.