Apostila de arquivo único — Deploy & Ferramentas. Uma aula por vez; use j/k ou o menu lateral. Voltar ao índice.

DeployUnidade 1 · Ferramentas e versionamento

Capítulo 01 — Caixa de ferramentas do dev web

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] 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.

🗺️ Roteiro

Bloco Tempo Atividade
1 40 min Terminal (qual usar, comandos de navegação) e VS Code (extensões, settings.json, atalhos, .editorconfig)
2 50 min DevTools na prática; Node.js LTS via nvm; npm, npx e a anatomia do package.json
3 60 min Passo a passo: ambiente pronto e um projeto de teste rodando no Live Server; Laboratório

1. Por que uma caixa de ferramentas

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.

2. O terminal

2.1 Qual terminal usar

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).

2.2 Onde estou, o que tem aqui, para onde vou

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 atual
cd Documentos  # 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
ls css       # 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íveis
cd ~         # vai para a sua pasta pessoal (home)
cd           # o mesmo que cd ~
cd -         # volta para a pasta em que você estava antes
cd /         # 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.

2.3 Criando e lendo arquivos

Terminal
mkdir projetos               # cria a pasta projetos
mkdir -p projetos/site/css   # cria a cadeia inteira de pastas de uma vez (-p = parents)
touch index.html             # cria um arquivo vazio (ou atualiza a data de um existente)
cat index.html               # mostra o conteúdo de um arquivo de texto na tela
cp index.html contato.html   # copia
mv contato.html paginas/     # move (ou renomeia, se o destino for um nome de arquivo)
rm rascunho.html             # apaga um arquivo — sem lixeira, sem desfazer
rm -r pasta-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ção rm 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.

2.4 Caminhos absolutos e relativos

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".

2.5 Truques que economizam horas

  • 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.

3. VS Code

3.1 Instalação e o comando code

Baixe em https://code.visualstudio.com e instale. Três detalhes por sistema:

  • Windows: no instalador, marque "Add to PATH" e as opções "Open with Code" no menu de contexto. Sem o PATH, code . não funciona no terminal.
  • macOS: abra o VS Code, aperte Cmd+Shift+P e execute Shell Command: Install 'code' command in PATH. Só assim code . funciona no Terminal.
  • Linux: o pacote .deb/.rpm já registra o comando. Em distribuições com Snap, sudo snap install code --classic.

Teste:

Terminal
code --version

A saída tem três linhas: a versão, um hash do build e a arquitetura (x64 ou arm64).

3.2 Extensões recomendadas

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
EditorConfig EditorConfig.EditorConfig Faz o editor obedecer ao .editorconfig do projeto

Instalação de todas de uma vez:

Terminal
code --install-extension ritwickdey.LiveServer
code --install-extension esbenp.prettier-vscode
code --install-extension dbaeumer.vscode-eslint
code --install-extension Vue.volar
code --install-extension humao.rest-client
code --install-extension eamodio.gitlens
code --install-extension EditorConfig.EditorConfig

Para conferir o que está instalado:

Terminal
code --list-extensions

💡 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.

3.3 settings.json

As configurações do VS Code vivem em um arquivo JSON. Abra-o com Ctrl+Shift+PPreferences: Open User Settings (JSON). Cole (ou mescle) isto:

settings.json (configurações do usuário)

JSON
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.tabSize": 2,
  "editor.insertSpaces": true,
  "editor.wordWrap": "on",
  "editor.linkedEditing": true,
  "editor.bracketPairColorization.enabled": true,
  "files.eol": "\n",
  "files.insertFinalNewline": true,
  "files.trimTrailingWhitespace": true,
  "emmet.variables": { "lang": "pt-BR" },
  "liveServer.settings.donotShowInfoMsg": true,
  "terminal.integrated.defaultProfile.windows": "Git Bash",
  "[markdown]": { "files.trimTrailingWhitespace": false }
}

O que cada linha faz:

  • 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.

3.4 Prettier: .prettierrc

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):

.prettierrc

JSON
{
  "semi": false,
  "singleQuote": true,
  "printWidth": 100,
  "trailingComma": "es5"
}

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ó.

3.5 .editorconfig

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 EditorConfig
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_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.

3.6 Atalhos essenciais

No macOS, troque Ctrl por Cmd e Alt por Option.

Atalho O que faz Quando usar
Ctrl+P Abre um arquivo pelo nome Projeto com muitas páginas
Ctrl+Shift+P Paleta de comandos Qualquer ação do editor sem procurar no menu
Ctrl+` Mostra/esconde o terminal integrado O tempo todo
Ctrl+B Mostra/esconde a barra lateral Mais espaço para o código
Ctrl+/ Comenta/descomenta a linha ou seleção Testar sem apagar
Alt+ / Move a linha atual Reordenar regras CSS
Shift+Alt+ Duplica a linha Repetir um <li>
Ctrl+D Seleciona a próxima ocorrência da palavra Renomear várias de uma vez
Ctrl+Shift+L Seleciona todas as ocorrências O mesmo, tudo de uma vez
F2 Renomeia o símbolo (variável, função) Refatorar JavaScript
Ctrl+Shift+F Busca em todos os arquivos Achar onde uma classe é usada
Shift+Alt+F Formata o documento Quando o formatOnSave não está ligado
Ctrl+, Abre as configurações Ajustar o editor
Ctrl+K Ctrl+S Lista todos os atalhos Aprender mais um por semana

3.7 Emmet: HTML em uma linha

O VS Code traz o Emmet embutido: você digita uma abreviação e aperta Tab. Em um arquivo .html:

  • ! → esqueleto completo (<!DOCTYPE html>, <html lang="pt-BR">, <meta viewport>, <title>).
  • ul>li*3 → uma lista com três itens.
  • .card>h2+p → uma div.card com um h2 e um p dentro.
  • a[href="contato.html"]{Contato} → um link com atributo e texto.
  • table>tr*3>td*2 → uma tabela 3×2.

Vale a pena passar dez minutos no cheat sheet oficial (https://docs.emmet.io/cheat-sheet/); as páginas do site do evento saem em metade do tempo.

4. DevTools: o raio-X do navegador

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.

4.1 Elements — o HTML como o navegador entendeu

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.

4.2 Console — o que o JavaScript tem a dizer

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.

4.3 Network — cada arquivo que a página pediu

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:

  1. Abra o DevTools antes de recarregar (F5), senão a aba fica vazia.
  2. Marque Disable cache enquanto desenvolve, para sempre ver a versão nova do CSS.
  3. Use o seletor de velocidade (No throttlingSlow 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.

4.4 Device toolbar — o site no celular

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.

4.5 Lighthouse — a nota do seu site

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.

4.6 Sources e Application — para depois

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.

5. Node.js e npm

5.1 Por que um dev front-end precisa de Node

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.

5.2 LTS e o gerenciador de versões

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.

Linux e macOS — nvm. Copie o comando de instalação da página oficial (https://github.com/nvm-sh/nvm#installing-and-updating); ele tem este formato, com a versão atual do nvm no meio da URL:

Terminal
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash

Feche e abra o terminal (o instalador acrescenta linhas ao ~/.bashrc ou ~/.zshrc, que só são lidas em um terminal novo). Depois:

Terminal
nvm install --lts          # baixa e instala a LTS mais recente
nvm use --lts              # ativa nesta sessão
nvm alias default 'lts/*'  # torna a LTS o padrão de todo terminal novo
nvm ls                     # 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.

Windows — nvm-windows. É um projeto diferente, com comandos parecidos. Baixe o nvm-setup.exe em https://github.com/coreybutler/nvm-windows/releases, instale e abra um Git Bash novo:

Terminal
nvm install lts    # instala a LTS mais recente
nvm list           # mostra o número exato da versão instalada
nvm use lts        # 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.

5.3 npm: os quatro comandos

Entre em uma pasta de projeto e:

Terminal
npm init -y

Cria um package.json com valores padrão (o -y aceita todas as perguntas). Em seguida:

Terminal
npm install prettier --save-dev   # instala um pacote como dependência de desenvolvimento
npm install                       # instala tudo que o package.json lista (ao clonar um projeto)
npm run formatar                  # executa o script "formatar" definido em package.json
npx prettier --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.

5.4 package.json explicado

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.

5.5 node_modules e package-lock.json

Duas regras que evitam metade dos problemas de Git do semestre:

  1. 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.
  2. 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.

6. Gerenciando versões

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
nvm current          # qual Node o nvm ativou nesta sessão (Linux/macOS)
which node           # de onde o executável node está vindo
npm ls --depth=0     # pacotes instalados no projeto, sem as dependências das dependências
npm outdated         # o que tem versão nova disponível
npm view vite version  # ú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.

Passo 1 — Git (e o Git Bash, no Windows)

  • 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.

Conferir (em um terminal novo):

Terminal
git --version

Esperado: git version 2.x.y.

Passo 2 — VS Code e o comando code

Instale conforme a §3.1 e, em um terminal novo:

Terminal
code --version

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.

Passo 3 — extensões

Terminal
code --install-extension ritwickdey.LiveServer
code --install-extension esbenp.prettier-vscode
code --install-extension dbaeumer.vscode-eslint
code --install-extension Vue.volar
code --install-extension humao.rest-client
code --install-extension eamodio.gitlens
code --install-extension EditorConfig.EditorConfig
code --list-extensions

Esperado: a última linha lista as sete extensões.

Passo 4 — settings.json

No VS Code, Ctrl+Shift+PPreferences: Open User Settings (JSON) → cole o conteúdo da §3.3 → salve. Se o arquivo já tinha conteúdo, mescle dentro das mesmas chaves { }.

Passo 5 — Node.js LTS

Siga a §5.2 para o seu sistema. Conferir, em um terminal novo:

Terminal
node -v
npm -v
npx -v

Esperado: v22.x.x (ou v24.x.x), 10.x.x (ou 11.x.x) e o mesmo número do npm.

Passo 6 — a pasta de projetos e o projeto de teste

Crie uma pasta para todos os projetos do WebLab e, dentro dela, o projeto de teste:

Terminal
mkdir -p ~/weblab/ola-weblab
cd ~/weblab/ola-weblab
mkdir css js
touch index.html css/estilo.css js/script.js
ls -R

Esperado:

Texto
.:
css  index.html  js

./css:
estilo.css

./js:
script.js

Abra no VS Code:

Terminal
code .

Passo 7 — os três arquivos

index.html (digite ! e Tab para o esqueleto, depois complete)

HTML
<!DOCTYPE html>
<html lang="pt-BR">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Olá, WebLab</title>
    <link rel="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>
      <p id="relogio">Carregando o relógio pelo JavaScript…</p>
    </main>
    <script src="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: 2rem auto;
  padding: 0 1rem;
  background: #f5f5f0;
  color: #222;
}

h1 {
  color: #1b6b4a;
}

js/script.js

JavaScript
// Mostra a hora atual e prova que o JavaScript foi carregado
const relogio = document.querySelector('#relogio')

function atualizarRelogio() {
  const agora = new Date()
  relogio.textContent = `Agora são ${agora.toLocaleTimeString('pt-BR')}`
}

atualizarRelogio()
setInterval(atualizarRelogio, 1000)

console.log('Ambiente WebLab: JavaScript carregado com sucesso')

Passo 8 — Live Server

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).

Passo 9 — npm init e os arquivos de padronização

No terminal integrado do VS Code (Ctrl+</kbd>), dentro deola-weblab`:

Terminal
npm init -y
npm install -D prettier serve

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
npm run formatar
npm run dev

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.

Como conferir

Comando ou ação Resultado esperado
node -v · npm -v · git --version v22.x (ou v24.x) · 10.x (ou 11.x) · 2.x
code --list-extensions As sete extensões da §3.2
Salvar um arquivo mal indentado O Prettier reorganiza na hora
Go Live Página em 127.0.0.1:5500, recarrega ao salvar
DevTools → Console A mensagem do console.log
DevTools → Network estilo.css e script.js com status 200
npm run (sem nome) Lista dev, formatar e verificar
ls -a Mostra .editorconfig, .prettierrc, node_modules, package.json, package-lock.json

🧪 Laboratório

Nível A — Fixação

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.

Nível B — Aplicação

B1. Estrutura em um comando. Crie, com um único mkdir e um único touch, 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.

Resultado esperado:

Texto
site-evento:
contato.html  css  img  index.html  inscricao.html  js  palestrantes.html  programacao.html

site-evento/css:
estilo.css

site-evento/img:

site-evento/js:
script.js
Dica

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
const   produtos=[{nome:"Café",preco:5},{nome:"Pão de queijo",
preco:4}]
function total( lista ){let soma=0;for(const p of lista){soma+=p.preco};return soma}
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.

Nível C — Desafio

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.

🏆 Desafios

⭐ Quanto pesa uma dependência?

terminalnodeinvestigacao

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
  1. find node_modules -type f | wc -l conta arquivos; du -sh node_modules mede o tamanho.
  2. Para contar pacotes no lock, grep -c '"node_modules/' package-lock.json é uma aproximação boa.
  3. npm ls --depth=0 mostra só o que você pediu; npm ls --all mostra a árvore inteira — compare os dois tamanhos.
  4. 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.
⭐⭐

⭐⭐ Dotfiles: seu VS Code em um comando

terminalprojeto

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
  1. code --list-extensions > extensoes.txt gera a lista; xargs -L1 code --install-extension < extensoes.txt instala linha a linha.
  2. 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á.
  3. Um case "$(uname -s)" in Linux*) … ;; Darwin*) … ;; MINGW*|MSYS*) … ;; esac escolhe o caminho.
  4. 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.
⭐⭐⭐

⭐⭐⭐ Detetive de performance

devtoolsperformanceinvestigacao

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
  1. 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.
  2. 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.
  3. A aba Performance grava um perfil: procure o marcador LCP (Largest Contentful Paint) e veja o que estava carregando antes dele.
  4. 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.

🐛 Erros comuns

Sintoma Causa Solução
bash: code: command not found (ou 'code' não é reconhecido) O VS Code não está no PATH macOS: paleta → Shell Command: Install 'code' command in PATH; Windows: reinstale marcando "Add to PATH"; sempre abra um terminal novo depois
npm.ps1 cannot be loaded because running scripts is disabled on this system Política de execução do PowerShell bloqueia scripts Use o Git Bash; ou, no PowerShell como administrador, Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
nvm: command not found logo após instalar O terminal atual não releu o ~/.bashrc/~/.zshrc Feche e abra o terminal (ou source ~/.bashrc); no macOS com zsh, confira se as linhas do nvm foram para ~/.zshrc
node -v mostra uma versão antiga depois de nvm install --lts Outro Node no PATH (instalador antigo) ou o alias default aponta para a versão velha which -a node para achar o intruso; nvm alias default 'lts/*'; desinstale o Node antigo
npm ERR! code EACCES ao instalar pacote global Node instalado com privilégios de administrador; sudo npm piora Instale o Node pelo nvm (fica na sua pasta pessoal, sem sudo)
npm ERR! enoent ENOENT: no such file or directory, open '…/package.json' Você rodou npm install ou npm run fora da pasta do projeto pwd para conferir, cd para a pasta certa; se for um projeto novo, npm init -y primeiro
Live Server abre uma lista de arquivos ou Cannot GET / Não há index.html na raiz da pasta aberta, ou você abriu um arquivo solto em vez da pasta Feche tudo e abra a pasta com code .; garanta que index.html está na raiz
A página não recarrega ao salvar Arquivo não salvo (bolinha na aba), ou a página foi aberta por dois cliques (file://) em vez do Go Live Salve com Ctrl+S; confira que a URL começa com http://127.0.0.1:5500
Get-ChildItem : A parameter cannot be found that matches parameter name 'la' ls -la no PowerShell, que só imita o ls do Linux Use o Git Bash (ou Get-ChildItem -Force no PowerShell)
Prettier não formata ao salvar editor.defaultFormatter não definido, ou outra extensão formata aquela linguagem Paleta → Format Document With…Configure Default Formatter → Prettier
warning: LF will be replaced by CRLF em todo git add (Windows) Fim de linha do Windows (CRLF) em conflito com o do projeto (LF) files.eol: "\n" no VS Code e git config --global core.autocrlf true; o Capítulo 02 explica

🏠 Para praticar depois da aula (1 h)

Monte o ambiente completo e crie a pasta do seu projeto autoral (o site com o tema que você escolheu na sua trilha):

  1. Siga o passo a passo até o fim; corrija qualquer item da tabela "Como conferir" que não bata.
  2. 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>.
  3. Abra com o Live Server e deixe um console.log com o nome do projeto no script.js.
  4. 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.

✅ Está no ar quando…

Nada vai ao ar neste capítulo — a bancada é que fica pronta. Confira:

  • [ ] node -v imprime v22.x.x ou v24.x.x; npm -v imprime 10.x.x ou superior; git --version imprime 2.x.y.
  • [ ] code . abre o VS Code na pasta atual, a partir do terminal recomendado para o seu sistema.
  • [ ] code --list-extensions lista Live Server, Prettier, ESLint, Vue - Official, REST Client, GitLens e EditorConfig.
  • [ ] Salvar um arquivo mal indentado o reformata (Prettier + formatOnSave).
  • [ ] O Live Server serve o index.html em 127.0.0.1:5500 e recarrega ao salvar.
  • [ ] Você encontra um console.log na aba Console e um 404 na aba Network sem ajuda.
  • [ ] A pasta do projeto autoral existe, com .editorconfig e .prettierrc na raiz e sem node_modules no que será entregue.
  • [ ] Você sabe explicar, em uma frase, a diferença entre npm install, npm run e npx.

📚 Para aprofundar

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.

🎯 Objetivos de aprendizagem📋 Pré-requisitos🗺️ Roteiro1. Por que uma caixa de ferramentas2. O terminal2.1 Qual terminal usar2.2 Onde estou, o que tem aqui, para onde vou2.3 Criando e lendo arquivos2.4 Caminhos absolutos e relativos2.5 Truques que economizam horas3. VS Code3.1 Instalação e o comando code3.2 Extensões recomendadas3.3 settings.json3.4 Prettier: .prettierrc3.5 .editorconfig3.6 Atalhos essenciais3.7 Emmet: HTML em uma linha4. DevTools: o raio-X do navegador4.1 Elements — o HTML como o navegador entendeu4.2 Console — o que o JavaScript tem a dizer4.3 Network — cada arquivo que a página pediu4.4 Device toolbar — o site no celular4.5 Lighthouse — a nota do seu site4.6 Sources e Application — para depois5. Node.js e npm5.1 Por que um dev front-end precisa de Node5.2 LTS e o gerenciador de versões5.3 npm: os quatro comandos5.4 package.json explicado5.5 node_modules e package-lock.json6. Gerenciando versões🚀 Passo a passo — Ambiente pronto e um projeto de teste no Live ServerPasso 1 — Git (e o Git Bash, no Windows)Passo 2 — VS Code e o comando codePasso 3 — extensõesPasso 4 — settings.jsonPasso 5 — Node.js LTSPasso 6 — a pasta de projetos e o projeto de testePasso 7 — os três arquivosPasso 8 — Live ServerPasso 9 — npm init e os arquivos de padronizaçãoComo conferir🧪 LaboratórioNível A — FixaçãoNível B — AplicaçãoNível C — Desafio🏆 Desafios⭐ Quanto pesa uma dependência?⭐⭐ Dotfiles: seu VS Code em um comando⭐⭐⭐ Detetive de performance🐛 Erros comuns🏠 Para praticar depois da aula (1 h)✅ Está no ar quando…📚 Para aprofundar
DeployUnidade 1 · Ferramentas e versionamento

Capítulo 02 — Git e GitHub do zero ao pull request

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] 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.

🗺️ Roteiro

Bloco Tempo Atividade
1 45 min Por que versionar; as três áreas do Git; configuração, primeiro repositório e histórico (§1 a §4)
2 45 min .gitignore e README.md; desfazer com segurança; branches, merge e conflitos (§5 a §7)
3 60 min GitHub, gh, pull request, stash e tags; passo a passo e Laboratório (§8 a §11)

1. Por que versionar

Abra a pasta de projetos antigos de qualquer colega e você encontra alguma variação disto:

Texto
site-final.zip   site-final-2.zip   site-final-agora-vai.zip
site-final-agora-vai-CORRIGIDO.zip   site-final-versao-do-joao.zip

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.

2. Como o Git pensa

2.1 Snapshots, não diferenças

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.

2.2 As três áreas

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.

2.3 HEAD, branch e a linha do tempo

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.

3. Configurando o Git

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:

Terminal
git config --global user.name "Ana Souza"
git config --global user.email "ana.souza@gmail.com"

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
git config --global init.defaultBranch main       # nome da branch inicial
git config --global core.editor "code --wait"     # VS Code como editor de mensagens
git config --global pull.rebase false             # git pull faz merge (padrão explícito)
git config --global core.autocrlf input           # Linux e macOS
git config --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.

4. O primeiro repositório

4.1 git init e o primeiro commit

Entre na pasta do projeto e transforme-a em repositório:

Terminal
cd ~/weblab/site-evento
git init
git status

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
git add .
git status -s
git commit -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.

4.3 Lendo o histórico

Terminal
git log --oneline                              # uma linha por commit
git log --oneline --graph --decorate --all     # com o desenho das branches
git log --stat                                 # quantas linhas mudaram em cada arquivo
git log -3                                     # só os três últimos
git log --author="Ana"                         # filtra por autor
git log -- 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).

4.4 git diff: o que exatamente mudou

Terminal
git diff              # diretório de trabalho × área de preparação
git diff --staged     # área de preparação × último commit
git diff HEAD         # diretório de trabalho × último commit (os dois juntos)
git diff main menu-responsivo   # diferença entre duas branches

Um diff se lê assim:

Texto
--- a/css/estilo.css
+++ b/css/estilo.css
@@ -12,7 +12,7 @@ header {
   display: flex;
-  background: #1b6b4a;
+  background: var(--cor-primaria);
   padding: 1rem;

- é 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.

5. .gitignore, README.md e .gitattributes

5.1 O que nunca entra no repositório

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.

5.2 README.md: a capa do projeto

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.

5.3 .gitattributes: o fim da guerra CRLF

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.

6. Desfazendo com segurança

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
git restore index.html            # descarta as mudanças de um arquivo (. = todos)
git restore --staged index.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
git reset --soft HEAD~1     # desfaz o commit; tudo volta para a área de preparação
git reset HEAD~1            # desfaz o commit; tudo volta para o diretório de trabalho
git reset --hard HEAD~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 revert nã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ção git 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.

7. Branches, merge e conflitos

7.1 Criar e trocar

Terminal
git switch -c menu-responsivo    # cria a branch e já muda para ela
git switch main                  # volta para main
git branch -a                    # lista as branches locais (* na atual) e as remotas
git branch -d menu-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.

7.2 Mesclar

Estando em main:

Terminal
git switch main
git merge menu-responsivo
git merge --no-ff menu-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.

7.3 Conflitos

Um conflito acontece quando as duas branches mudaram as mesmas linhas do mesmo arquivo. O Git não adivinha qual está certa e para:

Texto
Auto-merging index.html
CONFLICT (content): Merge conflict in index.html
Automatic merge failed; fix conflicts and then commit the result.

Abra o arquivo. O Git escreveu marcadores nele:

HTML
<h1>
<<<<<<< HEAD
  Semana Acadêmica de Sistemas de Informação
=======
  Semana Acadêmica de Sistemas de Informação  12ª edição
>>>>>>> menu-responsivo
</h1>

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
git diff --name-only --diff-filter=U   # lista só os arquivos ainda em conflito
git add index.html
git commit              # sem -m: o Git já sugere a mensagem de mesclagem
git merge --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.

8. GitHub e o gh

8.1 Git não é GitHub

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.

8.2 O GitHub CLI

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
sudo apt install gh                  # Ubuntu/Debian
brew install gh                      # macOS, com Homebrew
winget install --id GitHub.cli       # Windows, em um PowerShell

Se o apt da sua distribuição trouxer uma versão antiga, use o repositório oficial descrito em https://github.com/cli/cli/blob/trunk/docs/install_linux.md.

Conferir e autenticar com gh --version e gh auth login. O login faz quatro perguntas no terminal:

  1. What account do you want to log into?GitHub.com
  2. What is your preferred protocol for Git operations?HTTPS (mais simples; a §8.3 mostra a alternativa)
  3. Authenticate Git with your GitHub credentials?Yes (o gh passa a responder pelo git push, sem pedir senha)
  4. 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.

8.3 SSH, se você preferir

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 -t ed25519 -C "ana.souza@gmail.com"
gh ssh-key add ~/.ssh/id_ed25519.pub --title "Notebook da Ana"
ssh -T git@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.

8.4 Criar o repositório remoto e publicar

Dentro da pasta que já é um repositório local:

Terminal
gh repo create site-evento --public --source=. --remote=origin --push

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:

Terminal
git remote add origin https://github.com/ana-souza/site-evento.git
git remote -v
git push -u origin main

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.

9. Pull request: a conversa sobre o código

9.1 O que é

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

9.2 Abrindo o PR

Terminal
git switch -c menu-responsivo
git add css/estilo.css js/script.js index.html
git commit -m "Adiciona menu hamburguer responsivo abaixo de 768px"
git push -u origin menu-responsivo
gh pr create --base main --head menu-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.

9.3 Revisando

Terminal
gh pr list                          # PRs abertos no repositório
gh pr view 1                        # descrição, autor, estado
gh pr diff 1                        # o diff completo, no terminal
gh pr checkout 1                    # baixa a branch do PR para testar na sua máquina
gh pr checks 1                      # resultado das verificações automáticas
gh pr review 1 --comment --body "O botão precisa de aria-expanded."
gh pr review 1 --approve
gh pr review 1 --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.

9.4 Mesclando

Terminal
gh pr merge 1 --squash --delete-branch

Três estratégias, e quando usar cada uma:

Estratégia O que faz Quando usar
--merge 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.

9.5 Proteção de branch

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.

10. Stash, tags e o que mais você vai usar

10.1 git stash: guardar sem commitar

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
git stash push -u -m "menu pela metade"   # -u inclui arquivos não rastreados
git switch main
git switch menu-responsivo      # depois de resolver o urgente e commitar
git stash list                  # stash@{0}: On menu-responsivo: menu pela metade
git stash pop                   # 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).

10.2 Tags: marcar versões

Uma tag é um nome fixo para um commit. Serve para marcar entregas:

Terminal
git tag -a v1.0.0 -m "Marco 1: cinco páginas em HTML"
git push origin v1.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
gh release create v1.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."

10.3 Comandos de resgate

Terminal
git reflog                      # todo movimento do HEAD, inclusive o que "sumiu"
git blame css/estilo.css        # quem escreveu cada linha e em qual commit
git bisect start                # busca binária pelo commit que introduziu um bug
git clean -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.

11. Trabalhando em dupla

O trabalho em dupla (nesta trilha ou em qualquer projeto real) segue sempre o mesmo ciclo:

  1. Antes de começar a trabalhar, atualize: git switch main && git pull.
  2. Crie uma branch por tarefa (git switch -c inscricao-validacao) e faça commits pequenos e frequentes, um assunto por commit.
  3. 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.
  4. Abra o PR, peça revisão e responda aos comentários com novos commits — o PR se atualiza sozinho a cada push.
  5. 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:

Terminal
git switch main && git pull
git switch inscricao-validacao
git merge main

O push foi recusado.

Texto
! [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
git init
git add .
git status -s
git commit -m "Estrutura inicial do site do evento com as cinco páginas"
git log --oneline

Esperado: uma linha, com o hash curto e a mensagem.

Passo 2 — .gitignore, .gitattributes e README.md

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
git add .gitignore .gitattributes README.md
git commit -m "Adiciona README, .gitignore e .gitattributes"
mkdir -p node_modules && touch node_modules/teste.js .env
git status -s
rm -rf node_modules .env

Esperado: o git status -s não menciona node_modules nem .env.

Passo 3 — Autenticar o gh e publicar

Instale o GitHub CLI conforme a §8.2 e autentique-se, respondendo GitHub.com, HTTPS, Yes e Login with a web browser:

Terminal
gh --version
gh auth login
gh auth status
gh repo create site-evento --public --source=. --remote=origin --push
git remote -v
gh repo view --web

Esperado: o navegador abre o repositório com os arquivos e o README renderizado.

Passo 4 — Branch com a funcionalidade nova

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
<header class="cabecalho">
  <a class="logo" href="index.html">Semana Acadêmica</a>
  <button class="botao-menu" id="botaoMenu" aria-expanded="false"
          aria-controls="menuPrincipal" aria-label="Abrir menu de navegação"></button>
  <nav>
    <ul class="menu" id="menuPrincipal">
      <li><a href="index.html">Início</a></li>
      <li><a href="programacao.html">Programação</a></li>
      <li><a href="palestrantes.html">Palestrantes</a></li>
      <li><a href="inscricao.html">Inscrição</a></li>
      <li><a href="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.25rem 0.5rem;
}

@media (max-width: 768px) {
  .botao-menu { display: block; }
  .menu {
    display: none;
    flex-direction: column;
    width: 100%;
    gap: 0.5rem;
    padding: 1rem 0;
  }
  .menu.aberto { display: flex; }
}

js/script.js (acrescente ao fim do arquivo)

JavaScript
// Menu responsivo: abre e fecha a lista de links em telas estreitas
const botaoMenu = document.querySelector('#botaoMenu')
const menuPrincipal = document.querySelector('#menuPrincipal')

function definirEstado(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
git add index.html css/estilo.css js/script.js
git commit -m "Adiciona menu hamburguer responsivo abaixo de 768px"
git push -u origin menu-responsivo

Passo 5 — Abrir e revisar o pull request

Terminal
gh pr create --base main --head menu-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."
gh pr list
gh pr diff
gh pr review --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.

Passo 6 — Mesclar, limpar e marcar a entrega

Terminal
gh pr merge --squash --delete-branch
git switch main
git pull
git log --oneline --graph --decorate --all
git tag -a v1.0.0 -m "Site do evento com as cinco páginas e menu responsivo"
git push origin v1.0.0
gh release create v1.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."
gh repo view --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.

Como conferir

Comando ou ação Resultado esperado
git log --oneline Pelo menos três commits com mensagens no imperativo
git status nothing to commit, working tree clean
git status -s após criar .env Nenhuma linha para .env (está ignorado)
git remote -v origin com a URL do seu repositório, em fetch e push
gh pr list --state merged O PR do menu, com estado MERGED
git branch -a main local e remotes/origin/main
git show v1.0.0 --stat e a página do repositório O commit marcado com a lista de arquivos; README renderizado e a release na lateral

🧪 Laboratório

Nível A — Fixação

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?

Nível B — Aplicação

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 add só 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.

Nível C — Desafio

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.

🏆 Desafios

⭐ O que o .git guarda

gitterminalinvestigacao

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
  1. git cat-file -t <hash> diz o tipo de um objeto (commit, tree, blob); git cat-file -p <hash> mostra o conteúdo.
  2. O hash do último commit está em .git/refs/heads/main. Passe-o para o cat-file -p e siga o campo tree.
  3. git count-objects -vH resume quantos objetos existem e quanto ocupam, soltos e empacotados.
  4. 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.
⭐⭐

⭐⭐ Um histórico que conta a história

gitgithubrefatoracao

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
  1. 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.
  2. git add -p permite preparar só parte de um arquivo, o que é o que torna possível separar assuntos que você fez juntos.
  3. Para o PR, escolha uma melhoria real que ainda falta (acessibilidade, responsividade, tratamento de erro) em vez de inventar uma mudança cosmética.
  4. 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?
⭐⭐⭐

⭐⭐⭐ Caçada ao commit culpado

gitbuginvestigacao

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
  1. 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.
  2. git bisect run <comando> automatiza tudo: o Git roda o comando em cada passo e usa o código de saída como resposta.
  3. 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.
  4. 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.
  5. 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
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

🐛 Erros comuns

Sintoma Causa Solução
fatal: not a git repository (or any of the parent directories): .git Você está fora da pasta do projeto, ou nunca rodou git init pwd para conferir, cd para a pasta certa; se for projeto novo, git init
Author identity unknown *** Please tell me who you are. user.name/user.email não configurados Rode os dois git config --global da §3 e repita o commit
error: failed to push some refs to '…' hint: Updates were rejected because the remote contains work that you do not have locally O remoto tem commits que você não baixou git pull (resolva conflitos se houver) e depois git push; nunca --force em branch compartilhada
fatal: refusing to merge unrelated histories Repositório local e remoto começaram separados (você criou o repo no site com README e também deu git init local) git pull origin main --allow-unrelated-histories, resolva os conflitos e commite
CONFLICT (content): Merge conflict in index.html As duas branches mudaram as mesmas linhas Edite o arquivo, apague os marcadores, git add e git commit; para desistir, git merge --abort
error: Your local changes to the following files would be overwritten by checkout Você tenta trocar de branch com edições não commitadas em arquivos que mudam entre as branches git stash push -m "wip", troque de branch, git stash pop; ou commite antes
Can not approve your own pull request Você tentou gh pr review --approve no seu próprio PR Peça a revisão ao colega; em repositório pessoal, mescle sem aprovação
warning: LF will be replaced by CRLF in index.html Fim de linha diferente entre o sistema e o repositório Não é erro; configure core.autocrlf (§3) e adicione o .gitattributes da §5.3
fatal: The current branch menu-responsivo has no upstream branch Primeiro push de uma branch nova, sem -u git push -u origin menu-responsivo
node_modules aparece em git status mesmo com a linha no .gitignore A pasta já estava rastreada antes da regra git rm -r --cached node_modules e commite; o .gitignore passa a valer

🏠 Para praticar depois da aula (1 h)

Leve o seu projeto autoral (o site com o tema que você escolheu na sua trilha) para o GitHub, com histórico decente:

  1. 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).
  2. Faça no mínimo quatro commits separados por assunto, com mensagens no imperativo e menos de 60 caracteres cada.
  3. Publique com gh repo create <nome> --public --source=. --remote=origin --push.
  4. 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ê.
  5. 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.

✅ Está no ar quando…

  • [ ] 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 ….

📚 Para aprofundar

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.

🎯 Objetivos de aprendizagem📋 Pré-requisitos🗺️ Roteiro1. Por que versionar2. Como o Git pensa2.1 Snapshots, não diferenças2.2 As três áreas2.3 HEAD, branch e a linha do tempo3. Configurando o Git4. O primeiro repositório4.1 git init e o primeiro commit4.2 Mensagens de commit que servem para alguma coisa4.3 Lendo o histórico4.4 git diff: o que exatamente mudou5. .gitignore, README.md e .gitattributes5.1 O que nunca entra no repositório5.2 README.md: a capa do projeto5.3 .gitattributes: o fim da guerra CRLF6. Desfazendo com segurança7. Branches, merge e conflitos7.1 Criar e trocar7.2 Mesclar7.3 Conflitos8. GitHub e o gh8.1 Git não é GitHub8.2 O GitHub CLI8.3 SSH, se você preferir8.4 Criar o repositório remoto e publicar9. Pull request: a conversa sobre o código9.1 O que é9.2 Abrindo o PR9.3 Revisando9.4 Mesclando9.5 Proteção de branch10. Stash, tags e o que mais você vai usar10.1 git stash: guardar sem commitar10.2 Tags: marcar versões10.3 Comandos de resgate11. Trabalhando em dupla🚀 Passo a passo — O site do evento versionado, no GitHub e com um PR mescladoPasso 1 — Configurar o Git e iniciar o repositórioPasso 2 — .gitignore, .gitattributes e README.mdPasso 3 — Autenticar o gh e publicarPasso 4 — Branch com a funcionalidade novaPasso 5 — Abrir e revisar o pull requestPasso 6 — Mesclar, limpar e marcar a entregaComo conferir🧪 LaboratórioNível A — FixaçãoNível B — AplicaçãoNível C — Desafio🏆 Desafios⭐ O que o .git guarda⭐⭐ Um histórico que conta a história⭐⭐⭐ Caçada ao commit culpado🔥 Boss — A bancada inteira, versionada e publicada🐛 Erros comuns🏠 Para praticar depois da aula (1 h)✅ Está no ar quando…📚 Para aprofundar
DeployUnidade 2 · Publicação: estático, back-end, domínio e servidor

Capítulo 03 — Publicando sites estáticos

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] Repositório site-evento no GitHub, público, com README.md e histórico limpo (Capítulo 02) — ou o repositório do seu projeto autoral.
  • [ ] git e gh funcionando: gh auth status responde Logged in to github.com.
  • [ ] curl instalado (curl --version) e o navegador com DevTools (Capítulo 01).
  • [ ] Uma conta na Netlify (pode entrar com a conta do GitHub) — https://app.netlify.com.
  • [ ] 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".

🗺️ Roteiro

Bloco Tempo Atividade
1 45 min O que é hospedagem estática; GitHub Pages pelo painel e pelo gh; caminhos absolutos e relativos (§1 a §3)
2 45 min Favicon, 404.html, Netlify (arrastar-e-soltar, Git e netlify.toml), Vercel e Cloudflare Pages; cache e Lighthouse (§4 a §9)
3 60 min Passo a passo: site-evento no GitHub Pages e cafe-cerrado na Netlify + Laboratório

1. O que é uma hospedagem estática

1.1 Arquivos, não programas

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.

1.3 O que muda em relação ao Live Server

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

2. GitHub Pages

2.1 Os dois tipos de site

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.

2.2 Publicando pelo painel

O caminho oficial tem cinco cliques:

  1. Abra o repositório no GitHub (gh repo view --web faz isso do terminal).
  2. Settings (aba do topo, à direita) → Pages (menu da esquerda, seção Code and automation).
  3. Em Build and deployment → Source, escolha Deploy from a branch.
  4. Em Branch, escolha main e a pasta / (root). Clique em Save.
  5. 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.

2.3 Publicando pelo terminal, com o gh

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
gh api --method POST repos/{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
gh api repos/{owner}/{repo}/pages --jq '{status: .status, url: .html_url, branch: .source.branch}'

# Última construção: status e mensagem de erro, se houver
gh api repos/{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
gh api --method PUT repos/{owner}/{repo}/pages \
  -f "source[branch]=main" \
  -f "source[path]=/docs"

# Pedir uma reconstrução manual (útil quando a publicação travou)
gh api --method POST repos/{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.

2.4 As três manias do GitHub Pages

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
git add .nojekyll
git commit -m "Desliga o processamento Jekyll no GitHub Pages"
git push

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.

3.1 Os três tipos de caminho

Todo href, src e url() do seu código é resolvido pelo navegador contra a URL da página atual. Há três formas de escrever:

Forma Exemplo Como resolve
Relativo ao documento css/estilo.css, ./css/estilo.css, ../img/logo.png A partir da pasta da página atual
Relativo à raiz /css/estilo.css A partir da raiz do domínio, ignorando a pasta atual
Absoluto https://ana-souza.github.io/site-evento/css/estilo.css Endereço completo, ignora tudo

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.

3.2 Onde cada um vai parar

Suponha a página https://ana-souza.github.io/site-evento/programacao.html. Veja para onde o navegador manda cada requisição:

Escrito no HTML O navegador pede
css/estilo.css https://ana-souza.github.io/site-evento/css/estilo.css
./css/estilo.css https://ana-souza.github.io/site-evento/css/estilo.css
/css/estilo.css https://ana-souza.github.io/css/estilo.css ✘ 404
../css/estilo.css https://ana-souza.github.io/css/estilo.css ✘ 404
img/logo.png https://ana-souza.github.io/site-evento/img/logo.png
/img/logo.png https://ana-souza.github.io/img/logo.png ✘ 404

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.

3.3 O detalhe da barra final

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">.

3.4 Páginas em subpastas mudam a conta

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:

  1. Contar os ../ em cada página. Funciona, é chato e quebra quando você move um arquivo.
  2. Usar <base>. Uma única tag no <head> redefine a base de todos os caminhos relativos daquele documento:

blog/post-1.html

HTML
<head>
  <meta charset="UTF-8">
  <base href="/site-evento/">
  <link rel="stylesheet" href="css/estilo.css">
</head>

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.

  1. 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'

export default defineConfig({
  // 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
npm run build -- --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:

bash grep -rn 'href="/\|src="/\|url(/' --include='*.html' --include='*.css' --include='*.js' .

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.

3.5 Maiúsculas contam

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
git mv img/Logo.png img/temporario.png
git mv img/temporario.png img/logo.png
git commit -m "Padroniza o nome do logo em minusculas"

4. Favicon: o 404 que o Capítulo 01 deixou pendente

4.1 Por que ele dá 404 sozinho

Todo navegador, ao abrir qualquer página, pede automaticamente /favicon.ico na 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.

4.2 O conjunto que resolve na prática

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>
  <meta charset="UTF-8">
  <meta name="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 -->
  <link rel="icon" href="favicon.ico" sizes="32x32">
  <!-- Ícone vetorial: escala perfeito em qualquer densidade de tela -->
  <link rel="icon" href="img/favicon.svg" type="image/svg+xml">
  <!-- Ícone do atalho na tela inicial do iOS (180x180) -->
  <link rel="apple-touch-icon" href="img/apple-touch-icon.png">
  <!-- Metadados de aplicativo: nome curto, cores e ícones grandes -->
  <link rel="manifest" href="site.webmanifest">
  <meta name="theme-color" content="#0b3d2e">

  <link rel="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.

4.3 Fazendo os arquivos

Comece por um SVG quadrado simples — ícone bom é ícone que se reconhece a 16 pixels. Um monograma resolve:

img/favicon.svg

HTML
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Semana Academica">
  <rect width="64" height="64" rx="12" fill="#0b3d2e"/>
  <text x="32" y="43" font-family="system-ui, sans-serif" font-size="34"
        font-weight="700" fill="#ffffff" text-anchor="middle">SA</text>
</svg>

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):

Terminal
magick img/logo-512.png -resize 192x192 img/icone-192.png
magick img/logo-512.png -resize 180x180 img/apple-touch-icon.png
magick img/logo-512.png -resize 32x32 favicon.ico

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.

5. A página 404

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>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Página não encontrada — Semana Acadêmica</title>
  <link rel="icon" href="/site-evento/favicon.ico" sizes="32x32">
  <link rel="stylesheet" href="/site-evento/css/estilo.css">
</head>
<body>
  <main class="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><a href="/site-evento/">Ir para a página inicial</a></li>
      <li><a href="/site-evento/programacao.html">Ver a programação</a></li>
      <li><a href="/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 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.

6. Netlify

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:

  1. Abra https://app.netlify.com/drop (logado).
  2. Arraste a pasta do site (não um .zip, não os arquivos soltos) para a área indicada.
  3. Pronto. Em segundos você recebe uma URL como https://elegant-pasteur-1a2b3c.netlify.app.
  4. 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

  1. No painel, Add new site → Import an existing project → Deploy with GitHub. Autorize o acesso ao repositório cafe-cerrado.
  2. 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.
  3. 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.

6.3 netlify.toml: a configuração versionada

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 = 301
  force = 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.

6.4 A CLI da Netlify

Para quem prefere o terminal, ou para automatizar depois:

Terminal
# Autentica pelo navegador (uma vez por máquina)
npx --yes netlify-cli login

# Liga a pasta atual a um site já existente na sua conta
npx --yes netlify-cli link

# Publicação de rascunho: gera uma URL temporária para conferir
npx --yes netlify-cli deploy --dir=.

# Publicação definitiva, no endereço oficial do site
npx --yes netlify-cli deploy --prod --dir=. --message "Cardapio com fotos novas"

# Estado do site ligado a esta pasta
npx --yes netlify-cli status

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".

7. Vercel e Cloudflare Pages, em resumo

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 --yes vercel login
npx --yes vercel          # publica uma URL de pré-visualização
npx --yes vercel --prod   # publica no endereço de produção

A configuração versionada é o vercel.json:

vercel.json

JSON
{
  "cleanUrls": true,
  "redirects": [
    { "source": "/menu.html", "destination": "/cardapio.html", "permanent": true }
  ],
  "headers": [
    {
      "source": "/img/(.*)",
      "headers": [
        { "key": "Cache-Control", "value": "public, max-age=604800" }
      ]
    }
  ]
}

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:

Terminal
npx --yes wrangler login
npx --yes wrangler pages deploy . --project-name=site-evento

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

8.1 Quem guarda o quê

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:

Terminal
curl -I https://ana-souza.github.io/site-evento/
curl -I https://ana-souza.github.io/site-evento/css/estilo.css

Interessam quatro cabeçalhos da resposta:

Cabeçalho O que significa
cache-control: max-age=600 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.

8.2 Como forçar a versão nova

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
<link rel="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.

9. Medindo o site publicado com o Lighthouse

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:

Terminal
npx --yes lighthouse https://ana-souza.github.io/site-evento/ \
  --output html --output-path ./relatorio-antes.html --view

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.

Passo 1 — Confirme o ponto de partida

Terminal
cd ~/weblab/site-evento
git status
gh repo view --json name,visibility,url

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.

Passo 2 — Cace os caminhos que vão quebrar

Terminal
grep -rn 'href="/\|src="/\|url(/' --include='*.html' --include='*.css' --include='*.js' .

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 -1 img/
git add -A
git commit -m "Troca caminhos absolutos por relativos para publicar em subcaminho"

Passo 3 — Favicon, manifesto e 404

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:

  1. Cole o bloco de <link rel="icon"> da §4.2 no <head> das cinco páginas.
  2. Crie o site.webmanifest da §4.2 na raiz.
  3. Crie o 404.html da §5, com os caminhos começando em /site-evento/.
  4. Crie o arquivo que desliga o Jekyll:
Terminal
touch .nojekyll
git add -A
git commit -m "Adiciona favicon, manifesto, pagina 404 e .nojekyll"
git push

Passo 4 — Ligue o GitHub Pages

Pelo painel: Settings → Pages → Source: Deploy from a branch → Branch: main / (root) → Save.

Ou, sem sair do terminal:

Terminal
gh api --method POST repos/{owner}/{repo}/pages \
  -f "source[branch]=main" \
  -f "source[path]=/"

Acompanhe a publicação:

Terminal
gh api repos/{owner}/{repo}/pages --jq '{status: .status, url: .html_url}'

Repita até status virar built. Costuma levar menos de um minuto na primeira vez.

Passo 5 — Confira o que foi publicado

Terminal
curl -I https://ana-souza.github.io/site-evento/
curl -I https://ana-souza.github.io/site-evento/css/estilo.css
curl -I https://ana-souza.github.io/site-evento/pagina-inexistente

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.

Passo 6 — Conserte o que ainda estiver 404

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:

cafe-cerrado/netlify.toml

TOML
[build]
  publish = "."
  command = ""

[build.environment]
  NODE_VERSION = "22"

[[headers]]
  for = "/*"
  [headers.values]
    X-Content-Type-Options = "nosniff"
    Referrer-Policy = "strict-origin-when-cross-origin"

[[headers]]
  for = "/img/*"
  [headers.values]
    Cache-Control = "public, max-age=604800"
Terminal
cd ~/weblab/cafe-cerrado
git add netlify.toml
git commit -m "Configura publicacao e cabecalhos na Netlify"
git push

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.

Passo 9 — Meça os dois

Terminal
npx --yes lighthouse https://ana-souza.github.io/site-evento/ \
  --output html --output-path ./relatorio-site-evento.html --view

npx --yes lighthouse https://cafe-cerrado.netlify.app/ \
  --output html --output-path ./relatorio-cafe-cerrado.html --view

Anote as quatro notas de cada um em um arquivo medicoes.md e escolha três correções da tabela da §9 para aplicar hoje.

Como conferir

Terminal
curl -I https://ana-souza.github.io/site-evento/
curl -s https://ana-souza.github.io/site-evento/ | grep -c 'rel="icon"'
curl -I https://cafe-cerrado.netlify.app/img/xicara.png | grep -i cache-control
curl -I https://cafe-cerrado.netlify.app/pagina-inexistente

Resultado esperado:

  • o primeiro curl devolve HTTP/2 200 com server: GitHub.com;
  • o grep -c conta 2 (o .ico e o SVG) — se contar 0, o <link rel="icon"> não subiu;
  • o terceiro mostra cache-control: public, max-age=604800, provando que o netlify.toml está valendo;
  • o quarto devolve 404, servindo a sua página de erro;
  • nos dois sites, o DevTools em janela anônima não mostra nenhuma requisição em vermelho, e o ícone aparece na aba do navegador.

🧪 Laboratório

Nível A — Fixação

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?

Nível B — Aplicação

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.

Nível C — Desafio

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.

🏆 Desafios

⭐ A autópsia do site quebrado

deploygithubinvestigacao

É 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
  1. 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.
  2. 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.
  3. 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.
  4. 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.

⭐⭐

⭐⭐ Quatro hospedagens, um site, um veredicto

deployperformancehttp

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
  1. curl -o /dev/null -s -w 'total: %{time_total}s conexao: %{time_connect}s\n' <url> mede tempo sem baixar nada para a tela.
  2. Publique nas quatro a partir do mesmo commit; se uma delas construir o site e outra não, a comparação já nasce torta.
  3. 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.
  4. 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.
⭐⭐⭐

⭐⭐⭐ De 60 a 95 no Lighthouse, com prova

performancedeploydevtools

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
  1. 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%.
  2. width e height no <img> não mudam o peso, mas eliminam o deslocamento de layout — é a métrica CLS, e ela vale pontos.
  3. Fonte do Google Fonts: cada peso extra é um arquivo. Dois pesos bastam. font-display: swap evita texto invisível enquanto a fonte carrega.
  4. 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.
  5. Acessibilidade é a categoria com melhor retorno por minuto: contraste, alt, lang="pt-BR" e <label> costumam somar 20 pontos em meia hora.
  6. 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.

🐛 Erros comuns

Sintoma Causa Solução
404 There isn't a GitHub Pages site here. na URL do projeto A publicação ainda não terminou, ou o Source aponta para a branch errada Aba Actions → veja pages build and deployment; confira gh api repos/{owner}/{repo}/pages --jq .status
Site abre, mas sem CSS, sem imagem e sem JS Caminhos começando com / em um site de projeto (subcaminho) Troque por relativos ao documento; rode o grep da §3.4
Console: Failed to load resource: the server responded with a status of 404 () em /css/estilo.css O navegador pediu na raiz do domínio, não na pasta do repositório Remova a barra inicial: css/estilo.css
A imagem aparece na sua máquina e some no ar Nome com maiúscula ou acento: img/Logo.PNG × img/logo.png Renomeie tudo em minúsculas com o git mv de dois passos da §3.5
O Pages mostra o README.md renderizado em vez do site Não existe index.html na pasta publicada Confira o nome do arquivo e a pasta escolhida em Source; index.html, tudo minúsculo
Site Vite publicado sem estilo, com 404 em /_assets/… O Jekyll ignorou a pasta iniciada por sublinhado Crie o arquivo .nojekyll na raiz do site e publique de novo
Alterei, dei push, a publicação ficou verde e o site continua igual Cache do navegador ou da CDN Ctrl+F5, janela anônima, curl -I para ler age e cache-control
O favicon novo não aparece de jeito nenhum Favicon é o recurso com cache mais teimoso do navegador Abra a URL do ícone direto e recarregue forçado; ou publique com nome novo (favicon-2.ico)
Netlify: Page Not Found logo depois do primeiro deploy publish apontando para pasta errada (raiz em vez de dist, ou o contrário) Ajuste [build] publish no netlify.toml e refaça o deploy
Netlify: Build script returned non-zero exit code: 2 O comando de build falhou (dependência faltando, versão de Node diferente) Leia o log completo em Deploys; fixe NODE_VERSION em [build.environment]
Links funcionam na home e quebram nas páginas de subpasta Caminho relativo ao documento em profundidade diferente ../ a mais, ou uma <base href> no <head> (§3.4)
Tudo certo, mas a URL sem barra final quebra o CSS …/site-evento resolve os relativos como se estivesse na raiz Divulgue sempre a URL com a barra final: …/site-evento/

🏠 Para praticar depois da aula (1 h)

Publique o seu projeto autoral e deixe-o apresentável:

  1. 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.
  2. Publique no GitHub Pages (ou na Netlify, se o projeto precisar de redirecionamentos) e anote a URL completa, com barra final.
  3. Adicione favicon (.ico + SVG), apple-touch-icon, site.webmanifest e 404.html, todos funcionando na URL publicada.
  4. Rode o Lighthouse na URL pública e salve o relatório como relatorio-lighthouse.html no repositório.
  5. 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.

✅ Está no ar quando…

  • [ ] https://<seu-usuario>.github.io/site-evento/ abre em janela anônima, com estilo, imagens e o menu funcionando.
  • [ ] curl -I https://<seu-usuario>.github.io/site-evento/ devolve HTTP/2 200 com server: GitHub.com.
  • [ ] 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.

📚 Para aprofundar

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.

🎯 Objetivos de aprendizagem📋 Pré-requisitos🗺️ Roteiro1. O que é uma hospedagem estática1.1 Arquivos, não programas1.2 CDN: por que o site fica perto de quem acessa1.3 O que muda em relação ao Live Server2. GitHub Pages2.1 Os dois tipos de site2.2 Publicando pelo painel2.3 Publicando pelo terminal, com o gh2.4 As três manias do GitHub Pages3. Caminhos absolutos e relativos: o bug que aparece só depois de publicar3.1 Os três tipos de caminho3.2 Onde cada um vai parar3.3 O detalhe da barra final3.4 Páginas em subpastas mudam a conta3.5 Maiúsculas contam4. Favicon: o 404 que o Capítulo 01 deixou pendente4.1 Por que ele dá 404 sozinho4.2 O conjunto que resolve na prática4.3 Fazendo os arquivos5. A página 4046. Netlify6.1 Arrastar e soltar: publicando em quarenta segundos6.2 A partir do Git: o jeito que se usa de verdade6.3 netlify.toml: a configuração versionada6.4 A CLI da Netlify7. Vercel e Cloudflare Pages, em resumo8. Cache: por que o site velho insiste em aparecer8.1 Quem guarda o quê8.2 Como forçar a versão nova9. Medindo o site publicado com o Lighthouse🚀 Passo a passo — o site do evento no GitHub Pages e o Café Cerrado na NetlifyPasso 1 — Confirme o ponto de partidaPasso 2 — Cace os caminhos que vão quebrarPasso 3 — Favicon, manifesto e 404Passo 4 — Ligue o GitHub PagesPasso 5 — Confira o que foi publicadoPasso 6 — Conserte o que ainda estiver 404Passo 7 — Café Cerrado na Netlify, versão descartávelPasso 8 — Café Cerrado na Netlify, a partir do GitPasso 9 — Meça os doisComo conferir🧪 LaboratórioNível A — FixaçãoNível B — AplicaçãoNível C — Desafio🏆 Desafios⭐ A autópsia do site quebrado⭐⭐ Quatro hospedagens, um site, um veredicto⭐⭐⭐ De 60 a 95 no Lighthouse, com prova🐛 Erros comuns🏠 Para praticar depois da aula (1 h)✅ Está no ar quando…📚 Para aprofundar
DeployUnidade 2 · Publicação: estático, back-end, domínio e servidor

Capítulo 04 — Domínios, DNS e HTTPS

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] 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.

🗺️ Roteiro

Bloco Tempo Atividade
1 45 min Como um nome vira IP; registrar um domínio; tipos de registro (§1 a §3)
2 45 min Apontar para cada hospedagem; diagnosticar com dig; HTTPS, Let's Encrypt, HSTS e Cloudflare (§4 a §8)
3 60 min Passo a passo: evento.seudominio.dev com HTTPS no GitHub Pages + Laboratório

1. Como um nome vira um endereço IP

1.1 Anatomia de um nome

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.

1.2 Resolução recursiva, passo a passo

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.

1.3 TTL, cache e a tal "propagação"

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:

  1. Antes de mudar um registro importante, reduza o TTL para 300 (5 minutos) e espere o TTL antigo expirar.
  2. Faça a mudança.
  3. 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.

2. Registrando um domínio

2.1 Registro.br: domínios .br

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.

2.2 Domínios genéricos: .dev, .com, .app

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.

2.4 WHOIS: a certidão do domínio

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
sudo apt install whois        # Ubuntu/Debian; no macOS já vem
whois registro.br
whois ivanpires.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.

2.5 Três papéis que não se confundem

Papel O que faz Exemplos
Registrador 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.

3. Os tipos de registro que você vai usar

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ção CNAME 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.

4. Apontando o domínio para cada hospedagem

4.1 GitHub Pages

Para um subdomínio (evento.seudominio.dev): um único registro CNAME apontando para <usuario>.github.iosem 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 raiz evento.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 '/'.

4.2 Netlify

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.

4.3 Vercel

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.

4.4 Um VPS (servidor próprio)

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.

5. Ferramentas de diagnóstico

5.1 dig: a ferramenta principal

Terminal
dig evento.seudominio.dev                  # consulta A (padrão), saída completa
dig +short evento.seudominio.dev           # só a resposta
dig +short evento.seudominio.dev CNAME     # um tipo específico
dig +short seudominio.dev NS               # quem é autoritativo
dig +short seudominio.dev MX
dig +short seudominio.dev TXT
dig @1.1.1.1 +short evento.seudominio.dev  # pergunta a um resolvedor específico
dig +trace evento.seudominio.dev           # o caminho completo desde a raiz
dig +noall +answer evento.seudominio.dev   # só a seção ANSWER, com TTL
dig -x 185.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 respondeu127.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 +short evento.seudominio.dev @1.1.1.1
dig +short evento.seudominio.dev @8.8.8.8
dig +short evento.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).

5.2 nslookup e host

nslookup existe em todo Windows; host é um atalho curto no Linux/macOS:

Terminal
nslookup evento.seudominio.dev
nslookup -type=CNAME evento.seudominio.dev 8.8.8.8
host evento.seudominio.dev
host -t MX seudominio.dev
host -a seudominio.dev          # tudo o que conseguir

5.3 Caches locais e dnschecker.org

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
resolvectl flush-caches         # Ubuntu (systemd-resolved)
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder   # macOS
ipconfig /flushdns              # Windows (PowerShell/cmd)

O Chrome tem cache próprio: chrome://net-internals/#dnsClear 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.

6. HTTPS: o que o cadeado prova

6.1 Certificado, chaves e o que ele garante

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.

6.2 Cadeia de confiança

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".

6.3 Let's Encrypt e o protocolo ACME

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.

6.4 certbot e renovação automática

O certbot é o cliente ACME recomendado pela própria Let's Encrypt. Em um VPS com nginx, o fluxo inteiro é:

Terminal
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d evento.seudominio.dev
sudo certbot renew --dry-run     # simula a renovação: se passar, a automática vai passar
sudo certbot certificates        # 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ê.

6.5 HSTS: proibindo o HTTP de vez

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:

Texto
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

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?

bash openssl s_client -connect weblab.aprendabit.com:443 -servername weblab.aprendabit.com </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates curl -sI https://weblab.aprendabit.com | grep -i strict-transport

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.

7. Cloudflare: DNS e proxy na frente do site

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).

7.1 Os modos SSL/TLS

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.

8. Erros clássicos, explicados

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:

HTML
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">

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.

Passo 1 — confirme o ponto de partida

Terminal
curl -I https://usuario.github.io/site-evento/

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 +short seudominio.dev NS

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.

Passo 3 — crie o registro CNAME

No painel de DNS, adicione:

Texto
Tipo:   CNAME
Nome:   evento            (alguns painéis pedem o nome completo: evento.seudominio.dev)
Valor:  usuario.github.io
TTL:    300

Sem o nome do repositório no valor. Sem https://. Sem barra no final.

Passo 4 — confira o DNS antes de mexer no GitHub

Terminal
dig +short evento.seudominio.dev CNAME @1.1.1.1
dig +short evento.seudominio.dev A @1.1.1.1

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).

Passo 5 — informe o domínio ao GitHub

No repositório site-evento: Settings → Pages → Custom domain → digite evento.seudominio.devSave. O GitHub:

  1. cria um commit adicionando o arquivo CNAME (com uma linha: evento.seudominio.dev) na raiz da branch publicada — faça git pull para trazê-lo;
  2. mostra "DNS check in progress" e, em seguida, "DNS check successful";
  3. pede um certificado à Let's Encrypt. Isso leva de alguns minutos a, raramente, algumas horas.

Se o site é gerado por Actions, adicione você mesmo o arquivo public/CNAME ao projeto, com o nome do domínio, e faça commit.

Passo 6 — corrija os caminhos

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.

Passo 7 — force HTTPS

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.

Passo 8 — se você usa Cloudflare

Só agora ligue o proxy (nuvem laranja) no registro evento, e confirme que SSL/TLS está em Full (strict) e Always Use HTTPS está ativo.

Como conferir

Terminal
curl -I https://evento.seudominio.dev
curl -I http://evento.seudominio.dev
openssl s_client -connect evento.seudominio.dev:443 -servername evento.seudominio.dev </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates

Resultado esperado:

  • 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.

🧪 Laboratório

Nível A — Fixação

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?

Nível B — Aplicação

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.

Nível C — Desafio

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.

🏆 Desafios

⭐ O caminho completo de um nome

dnsterminalinvestigacao

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
  1. Leia a seção +trace em man dig: ele ignora o resolvedor recursivo e faz as perguntas ele mesmo, começando pela raiz.
  2. Cada bloco da saída termina com ;; Received … from <IP>#53(<nome>) — esse é o servidor que respondeu naquele nível.
  3. 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.
  4. Sem cache, o TTL mostrado é sempre o valor original configurado na zona.
⭐⭐

⭐⭐ Um domínio, três hospedagens

dnsdeploygithubhttps

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
  1. 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.
  2. 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.
  3. Se a caixa Enforce HTTPS não liberar, dig o nome de novo: certificado só é emitido quando o DNS resolve para a plataforma.
  4. Para o registro A de api, qualquer IP funciona para o dig; o HTTPS dele fica para o Capítulo 06.
⭐⭐⭐

⭐⭐⭐ Certificado curinga com desafio DNS

httpsdnsterminalseguranca

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.dev e seudominio.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
  1. Leia "Wildcard certificates" na documentação da Let's Encrypt e a página do certbot sobre o modo --manual.
  2. 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.1 antes de apertar Enter — se apertar antes, o desafio falha e você gasta cota. Comece com --dry-run.
  3. Um curinga cobre exatamente um nível: *.seudominio.dev inclui cafe.seudominio.dev, mas não api.cafe.seudominio.dev nem o apex.
  4. Existem plugins python3-certbot-dns-cloudflare, dns-route53 e outros que criam o TXT via API; com eles a renovação vira automática.

🐛 Erros comuns

Sintoma Causa Solução
DNS_PROBE_FINISHED_NXDOMAIN no navegador, mas o registro "está lá" no painel Registro criado no provedor errado: os NS do domínio apontam para outro lugar (Cloudflare, Netlify DNS) dig +short seudominio.dev NS; edite a zona nesse provedor
dig @1.1.1.1 responde certo, o navegador insiste no erro Cache do sistema ou do Chrome, ou cache negativo de um teste feito antes de criar o registro resolvectl flush-caches (Ubuntu) e chrome://net-internals/#dns → Clear host cache
NET::ERR_CERT_COMMON_NAME_INVALID ao abrir o subdomínio novo O servidor respondeu com o certificado de outro site: GitHub ainda não emitiu o certificado, ou o nginx do VPS não tem server para esse nome No Pages, espere o DNS check e o Enforce HTTPS; no VPS, rode certbot --nginx -d nome
NET::ERR_CERT_AUTHORITY_INVALID Certificado autoassinado, certificado de origem da Cloudflare acessado sem o proxy, ou intermediária faltando Emita pela Let's Encrypt; no nginx use fullchain.pem, não cert.pem
ERR_TOO_MANY_REDIRECTS Cloudflare em modo Flexible com origem que redireciona para HTTPS SSL/TLS → Full (strict); certifique a origem
Console: Mixed Content: … requested an insecure resource 'http://…'. This request has been blocked Página HTTPS pedindo script, CSS ou fetch por http:// Troque para https://; provisoriamente, <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">
Painel diz "Enforce HTTPS: Unavailable for your site because your domain is not properly configured" O CNAME/A ainda não resolve para o GitHub, ou o proxy da Cloudflare está ligado antes da emissão Confira o dig @1.1.1.1; na Cloudflare, deixe DNS only até o certificado sair
Site abre em evento.seudominio.dev, mas sem CSS e imagens Links absolutos com o nome do repositório (/site-evento/css/…) — o site agora está na raiz Use caminhos relativos; em projetos Vite, base: '/'
curl: (60) SSL certificate problem: unable to get local issuer certificate Servidor envia só o certificado folha, sem a intermediária Configure ssl_certificate com fullchain.pem
ERR_SSL_PROTOCOL_ERROR Porta 443 respondendo HTTP puro (listen 443 sem ssl) Rode o certbot --nginx ou adicione ssl e os caminhos do certificado

🏠 Para praticar depois da aula (1 h)

No seu projeto autoral do Nível 1 (ou do nível que você está cursando), já publicado no Capítulo 03:

  1. 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.
  2. Crie o registro DNS correto para a plataforma onde o site está e cadastre o domínio nela.
  3. Force HTTPS e elimine qualquer conteúdo misto.
  4. 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.

✅ Está no ar quando…

  • [ ] dig +short evento.seudominio.dev @1.1.1.1 devolve o alvo certo (CNAME da plataforma ou A do VPS) — e @8.8.8.8 concorda.
  • [ ] curl -I https://evento.seudominio.dev devolve HTTP/2 200 (ou HTTP/1.1 200) sem erro de certificado.
  • [ ] curl -I http://evento.seudominio.dev devolve 301/308 com location: https://….
  • [ ] openssl s_client … | openssl x509 -noout -subject -dates mostra o seu nome no subject e validade futura.
  • [ ] O console do navegador não tem mensagens Mixed Content.
  • [ ] Você sabe dizer, sem olhar, quem é o registrador, quem é o provedor de DNS e quem é a hospedagem do seu domínio.
  • [ ] O TTL dos registros voltou para 3600 depois que tudo estabilizou.

📚 Para aprofundar

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.

🎯 Objetivos de aprendizagem📋 Pré-requisitos🗺️ Roteiro1. Como um nome vira um endereço IP1.1 Anatomia de um nome1.2 Resolução recursiva, passo a passo1.3 TTL, cache e a tal "propagação"2. Registrando um domínio2.1 Registro.br: domínios .br2.2 Domínios genéricos: .dev, .com, .app2.3 Sem dinheiro para um domínio? Opções para estudar2.4 WHOIS: a certidão do domínio2.5 Três papéis que não se confundem3. Os tipos de registro que você vai usar4. Apontando o domínio para cada hospedagem4.1 GitHub Pages4.2 Netlify4.3 Vercel4.4 Um VPS (servidor próprio)5. Ferramentas de diagnóstico5.1 dig: a ferramenta principal5.2 nslookup e host5.3 Caches locais e dnschecker.org6. HTTPS: o que o cadeado prova6.1 Certificado, chaves e o que ele garante6.2 Cadeia de confiança6.3 Let's Encrypt e o protocolo ACME6.4 certbot e renovação automática6.5 HSTS: proibindo o HTTP de vez7. Cloudflare: DNS e proxy na frente do site7.1 Os modos SSL/TLS8. Erros clássicos, explicados🚀 Passo a passo — evento.seudominio.dev com HTTPSPasso 1 — confirme o ponto de partidaPasso 2 — descubra quem responde pelo seu domínioPasso 3 — crie o registro CNAMEPasso 4 — confira o DNS antes de mexer no GitHubPasso 5 — informe o domínio ao GitHubPasso 6 — corrija os caminhosPasso 7 — force HTTPSPasso 8 — se você usa CloudflareComo conferir🧪 LaboratórioNível A — FixaçãoNível B — AplicaçãoNível C — Desafio🏆 Desafios⭐ O caminho completo de um nome⭐⭐ Um domínio, três hospedagens⭐⭐⭐ Certificado curinga com desafio DNS🐛 Erros comuns🏠 Para praticar depois da aula (1 h)✅ Está no ar quando…📚 Para aprofundar
DeployUnidade 2 · Publicação: estático, back-end, domínio e servidor

Capítulo 05 — Publicando o back-end Node

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] 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.

🗺️ Roteiro

Bloco Tempo Atividade
1 45 min Estático × servidor; onde um processo Node pode morar; como uma PaaS constrói e roda o código (§1 a §3)
2 50 min Preparar a API: porta, host, scripts, variáveis de ambiente, health check, logs e CORS (§4 a §8)
3 55 min Passo a passo: cafe-cerrado-api no Render com o front consumindo a URL pública + Laboratório

1. Estático × servidor: o que realmente muda

1.1 O que uma hospedagem estática faz

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.

1.2 O que um servidor de aplicação faz

A cafe-cerrado-api é outra coisa. Ela é um processo do sistema operacional que:

  1. sobe, lê a configuração e abre uma porta TCP;
  2. fica parado esperando conexões, para sempre;
  3. a cada requisição, executa o seu código: lê o dados/produtos.json, valida um ID token do Google, grava uma alteração, monta uma resposta JSON;
  4. guarda coisas em memória enquanto está vivo;
  5. morre se você derrubar, se a máquina reiniciar, se uma exceção não tratada escapar — e alguém precisa trazê-lo de volta.

O resumo em uma tabela:

Aspecto Site estático API Node
O que é publicado arquivos um processo em execução
Quem executa código só o navegador o servidor e o navegador
Se cair não cai (é só arquivo em cache) precisa de alguém que reinicie
Segredos não pode ter nenhum vivem no ambiente do processo

1.3 As quatro consequências práticas

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.

2. Onde um processo Node pode morar

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.

2.1 Render — o principal deste capítulo

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.

2.2 Railway — resumo

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:

Terminal
npm install -g @railway/cli
railway login
railway init
railway up
railway variables
railway logs

Use-o quando quiser um banco gerenciado junto da aplicação sem configurar nada — e quando o cartão puder entrar na história.

2.3 Fly.io — resumo

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.

Terminal
curl -L https://fly.io/install.sh | sh
fly auth login
fly launch --region gru
fly secrets set GOOGLE_CLIENT_ID=seu-client-id CORS_ORIGENS=https://cafe.seudominio.dev
fly deploy
fly logs
fly status

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.

3. Como uma PaaS constrói e roda o seu código

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":

  1. 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.
  2. Build. Roda o build command. Para uma API Node, quase sempre npm ci. Para um front com Vite, npm ci && npm run build.
  3. Start. Roda o start command (npm start) e injeta as variáveis de ambiente, incluindo a PORT.
  4. 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.
  5. 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).

4. Preparando a API para produção

Cinco mudanças pequenas no código, todas no repositório da cafe-cerrado-api.

4.1 A porta vem do ambiente

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.

const ambiente = process.env.NODE_ENV ?? 'development';
const producao = ambiente === 'production';

function texto(nome, padrao) {
  const valor = process.env[nome] ?? padrao;
  if (valor === undefined || valor === '') {
    throw new Error(`Configuração ausente: defina ${nome} no ambiente (ou no .env).`);
  }
  return valor;
}

function numero(nome, padrao) {
  const valor = Number(texto(nome, padrao));
  if (!Number.isInteger(valor) || valor <= 0) {
    throw new Error(`Configuração inválida: ${nome} precisa ser um inteiro positivo.`);
  }
  return valor;
}

function lista(nome, padrao) {
  return texto(nome, padrao)
    .split(',')
    .map((item) => item.trim())
    .filter((item) => item.length > 0);
}

export const config = {
  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'),
};

4.2 Escutar em 0.0.0.0 e encerrar com educação

src/server.js

JavaScript
// 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';

const servidor = app.listen(config.porta, config.host, () => {
  console.log(`[cafe-cerrado-api] no ar em ${config.host}:${config.porta} (${config.ambiente})`);
});

function encerrar(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.

4.3 package.json: o contrato com a plataforma

package.json

JSON
{
  "name": "cafe-cerrado-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.0.0"
  },
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch --env-file=.env src/server.js",
    "test": "node --test"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^5.1.0",
    "google-auth-library": "^9.15.0"
  }
}

Quatro campos que a plataforma lê:

  • "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.

4.4 npm ci, não npm install

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.json precisa 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.

4.5 Confiar no proxy

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.

5. Configuração e segredos

5.1 A regra

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.

5.2 .env na sua máquina, painel no servidor

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=.env src/server.js
npm run dev

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.

5.3 No Render

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.

5.4 Vazou um segredo. E agora?

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:

  1. 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.
  2. Coloque o novo valor no painel da plataforma e no seu .env local.
  3. Só então limpe o histórico, se valer a pena.
  4. 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".

6. Health check e logs

6.1 A rota de saúde

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).
import express from 'express';
import cors from 'cors';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from './config.js';
import rotasProdutos from './rotas/produtos.js';

const aqui = path.dirname(fileURLToPath(import.meta.url));

export const app = 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/saude e /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.

6.2 O que o health check não deve fazer

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.

6.3 Logs: stdout é o log

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:

  1. Uma linha por evento, com um prefixo que identifique de onde veio ([cafe-cerrado-api], [erro]). Painel de logs é lido com busca textual.
  2. Nunca logue segredo. console.log(config) parece inofensivo até imprimir o client ID e a senha do banco em texto puro num painel compartilhado.
  3. 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.
export function registrarAcesso(requisicao, resposta, proximo) {
  const inicio = process.hrtime.bigint();
  resposta.on('finish', () => {
    const ms = 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].

bash curl -i http://localhost:3000/api/saude curl -i http://localhost:3000/api/produtos curl -i http://localhost:3000/api/nao-existe

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?

7. CORS: liberando o front publicado

7.1 O que o navegador faz

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:

HTTP
Access-Control-Allow-Origin: https://cafe-cerrado.netlify.app

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.

7.2 A requisição de verificação prévia

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.

HTTP
OPTIONS /api/produtos HTTP/1.1
Origin: https://cafe-cerrado.netlify.app
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

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.

7.3 Configurando de verdade

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.

JavaScript
app.use(
  cors({
    origin: config.origensPermitidas,
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    maxAge: 86400,
  }),
);

E, no painel do Render:

Texto
CORS_ORIGENS=https://cafe-cerrado.netlify.app,https://cafe.seudominio.dev

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ção origin: '*' 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'.

7.4 CORS não é segurança do servidor

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.

8. As três plataformas lado a lado

Critério Render Railway / Fly.io
Plano gratuito sim, com cold start e disco efêmero crédito de teste; depois, por uso
Região mais próxima EUA / Europa / Ásia Fly.io tem São Paulo (gru)
Como se publica conectar o repositório no painel linha de comando (railway up, fly deploy)
Melhor para primeiro deploy e projetos de estudo 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.

🚀 Passo a passo — a cafe-cerrado-api no Render

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.

Passo 1 — deixe o repositório pronto

Na sua máquina, na raiz da cafe-cerrado-api:

Terminal
node -v                       # precisa ser 22.x
npm ci                        # falha aqui? o package-lock.json está inconsistente
npm start                     # sobe em 0.0.0.0:3000
curl -s http://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
git ls-files | grep -E "package-lock|\.env"

Deve aparecer package-lock.json e .env.example — e não deve aparecer .env.

Passo 2 — aplique as mudanças das §4 a §7

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
git add .
git commit -m "prepara a API para produção: PORT, config, health check e CORS"
git push

Passo 3 — crie o serviço no Render

No painel: New → Web Service → Build and deploy from a Git repository, autorize o GitHub e escolha o repositório cafe-cerrado-api. Preencha:

Texto
Name:              cafe-cerrado-api
Language:          Node
Branch:            main
Region:            Oregon (US West)
Root Directory:    (vazio — o package.json está na raiz)
Build Command:     npm ci
Start Command:     npm start
Instance Type:     Free

Passo 4 — cadastre as variáveis de ambiente

Ainda na tela de criação, em Environment Variables:

Texto
NODE_ENV=production
NODE_VERSION=22
CORS_ORIGENS=https://cafe-cerrado.netlify.app
GOOGLE_CLIENT_ID=o-client-id-que-esta-no-seu-.env

Não crie PORT: o Render injeta essa variável e sobrescrever pode quebrar o serviço. Clique em Deploy Web Service.

Passo 5 — acompanhe o primeiro deploy pelos logs

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.

Passo 6 — configure o health check

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.

Passo 7 — teste a API pública

Terminal
curl -i https://cafe-cerrado-api.onrender.com/api/saude
curl -s https://cafe-cerrado-api.onrender.com/api/produtos | head -c 300
curl -i -X POST https://cafe-cerrado-api.onrender.com/api/produtos \
  -H "Content-Type: application/json" \
  -d '{"nome":"Café coado","preco":7.5}'

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.

Passo 8 — ligue o front publicado à API

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.
const local = ['localhost', '127.0.0.1'].includes(window.location.hostname);

export const API_URL = local
  ? 'http://localhost:3000'
  : 'https://cafe-cerrado-api.onrender.com';

export async function buscarProdutos() {
  const resposta = await fetch(`${API_URL}/api/produtos`);
  if (!resposta.ok) {
    throw new Error(`A API respondeu ${resposta.status} ${resposta.statusText}`);
  }
  return resposta.json();
}

export async function acordarApi() {
  // 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 {
    await fetch(`${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.

Passo 9 — confirme o CORS com a origem real

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.

Passo 10 — deploy automático e rollback

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.

Como conferir

Terminal
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" https://cafe-cerrado-api.onrender.com/api/saude
curl -s -H "Origin: https://cafe-cerrado.netlify.app" -i \
  https://cafe-cerrado-api.onrender.com/api/produtos | grep -i access-control
curl -s -H "Origin: https://site-aleatorio.example" -i \
  https://cafe-cerrado-api.onrender.com/api/produtos | grep -i access-control

Resultado esperado:

  • o primeiro comando devolve 200 e um tempo total abaixo de 1 s (a não ser que o serviço estivesse dormindo — repita e compare os dois tempos);
  • o segundo mostra access-control-allow-origin: https://cafe-cerrado.netlify.app;
  • o terceiro não mostra nenhum cabeçalho access-control-allow-origin — a origem desconhecida não foi liberada;
  • no site publicado, o cardápio carrega os produtos vindos do Render e o console não tem erros;
  • no painel do Render, a aba Logs mostra as linhas [acesso] GET /api/produtos 200 correspondentes às suas visitas.

🧪 Laboratório

Nível A — Fixação

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.

Nível B — Aplicação

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').

Nível C — Desafio

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.

🏆 Desafios

⭐ Health check que diz a verdade

nodeexpressapiinvestigacao

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
  1. fs.promises.access(caminho, fs.constants.R_OK) resolve ou rejeita — é a verificação mais barata de "existe e consigo ler".
  2. 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.
  3. Para quebrar de propósito sem apagar nada: renomeie o arquivo, ou mude a permissão com chmod 000.
  4. 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.
⭐⭐

⭐⭐ Uma API, três ambientes

deploynodesegurancaprojeto

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 desenvolvimento nã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
  1. Ao criar o segundo serviço, mude apenas Branch e Name; todo o resto é igual. As variáveis são independentes por serviço.
  2. 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.
  3. Dois serviços gratuitos consomem a mesma cota mensal de horas da conta. Suspenda o de homologação quando não estiver usando.
  4. Se o pull request mesclado não disparar deploy, confira em Settings se o Auto-Deploy está ligado e se a branch está certa.
⭐⭐⭐

⭐⭐⭐ Sobreviva ao disco efêmero

deploynodeapibanco-de-dados

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
  1. 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.
  2. 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.
  3. 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).
  4. 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.

🐛 Erros comuns

Sintoma Causa Solução
Painel: ==> No open ports detected, continuing to scan... e o deploy expira processo escutando em 127.0.0.1 ou em porta fixa, ignorando process.env.PORT app.listen(process.env.PORT ?? 3000, '0.0.0.0')
Build falha com The npm ci command can only install with an existing package-lock.json lock ausente ou dessincronizado do package.json rode npm install local, commite o package-lock.json; nunca o coloque no .gitignore
Start falha com Error: Cannot find module 'express' dependência declarada em devDependencies, ou build command errado mova para dependencies; confirme npm ci como build command
Start falha com SyntaxError: Cannot use import statement outside a module import/export sem "type": "module" no package.json adicione o campo, ou converta o arquivo para require
Console do site: blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present a origem publicada não está em CORS_ORIGENS, ou entrou com barra final copie a origem exata da mensagem, sem barra, e salve a variável no painel
O GET funciona, o POST falha e não aparece no log do servidor a verificação prévia (OPTIONS) foi bloqueada antes da requisição real libere methods e allowedHeaders no cors; não intercepte OPTIONS antes dele
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' origin: '*' combinado com credentials: true liste as origens explicitamente
Primeira requisição do dia leva quase um minuto; as seguintes, milissegundos cold start: o plano gratuito desligou o serviço após ~15 min sem tráfego acorde no carregamento da página (Passo 8), avise o usuário, ou pague um plano que não dorme
Produtos criados somem depois de um git push disco efêmero: o sistema de arquivos volta ao estado do repositório a cada deploy banco de dados (Capítulo 08) ou armazenamento externo
Processo morre no start com Configuração ausente: GOOGLE_CLIENT_ID variável não cadastrada no painel cadastre em Environment; compare com o .env.example
Error: listen EADDRINUSE: address already in use :::3000 na sua máquina outro processo (ou uma instância antiga da API) segurando a porta lsof -i :3000 e encerre o processo, ou rode com outra PORT
Deploy "bem-sucedido" mas o site responde 502 o processo caiu logo depois de subir; o health check não estava configurado veja os logs no minuto do deploy; defina o Health Check Path (Passo 6)
Segredo apareceu no log do painel algum console.log imprimindo o objeto de configuração inteiro remova o log, revogue e gere outro segredo (§5.4)

🏠 Para praticar depois da aula (1 h)

Na API do seu projeto autoral (ou na cafe-cerrado-api, se o seu projeto ainda não tem back-end):

  1. Aplique as cinco preparações da §4: PORT do ambiente, 0.0.0.0, engines, script start e tratamento de SIGTERM.
  2. 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 .env não está versionado.
  3. Publique no Render (ou Railway/Fly.io) com health check configurado e deploy automático a partir da main.
  4. Ligue o front publicado à URL da API, com CORS_ORIGENS liberando exatamente aquela origem.
  5. 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.

✅ Está no ar quando…

  • [ ] 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.

📚 Para aprofundar

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/.

🎯 Objetivos de aprendizagem📋 Pré-requisitos🗺️ Roteiro1. Estático × servidor: o que realmente muda1.1 O que uma hospedagem estática faz1.2 O que um servidor de aplicação faz1.3 As quatro consequências práticas2. Onde um processo Node pode morar2.1 Render — o principal deste capítulo2.2 Railway — resumo2.3 Fly.io — resumo3. Como uma PaaS constrói e roda o seu código4. Preparando a API para produção4.1 A porta vem do ambiente4.2 Escutar em 0.0.0.0 e encerrar com educação4.3 package.json: o contrato com a plataforma4.4 npm ci, não npm install4.5 Confiar no proxy5. Configuração e segredos5.1 A regra5.2 .env na sua máquina, painel no servidor5.3 No Render5.4 Vazou um segredo. E agora?6. Health check e logs6.1 A rota de saúde6.2 O que o health check não deve fazer6.3 Logs: stdout é o log7. CORS: liberando o front publicado7.1 O que o navegador faz7.2 A requisição de verificação prévia7.3 Configurando de verdade7.4 CORS não é segurança do servidor8. As três plataformas lado a lado🚀 Passo a passo — a cafe-cerrado-api no RenderPasso 1 — deixe o repositório prontoPasso 2 — aplique as mudanças das §4 a §7Passo 3 — crie o serviço no RenderPasso 4 — cadastre as variáveis de ambientePasso 5 — acompanhe o primeiro deploy pelos logsPasso 6 — configure o health checkPasso 7 — teste a API públicaPasso 8 — ligue o front publicado à APIPasso 9 — confirme o CORS com a origem realPasso 10 — deploy automático e rollbackComo conferir🧪 LaboratórioNível A — FixaçãoNível B — AplicaçãoNível C — Desafio🏆 Desafios⭐ Health check que diz a verdade⭐⭐ Uma API, três ambientes⭐⭐⭐ Sobreviva ao disco efêmero🐛 Erros comuns🏠 Para praticar depois da aula (1 h)✅ Está no ar quando…📚 Para aprofundar
DeployUnidade 2 · Publicação: estático, back-end, domínio e servidor

Capítulo 06 — Servidor próprio (VPS) com nginx

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] 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ê.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min 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ê.

1.1 O que você ganha

  • 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.

1.2 O que você assume

  • 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).

1.3 Como escolher

Escolha Quando
PaaS (Capítulo 05) 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.

2. Escolhendo e criando o VPS

2.1 Provedores

Provedor Observação
Hetzner, Contabo 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.

2.2 Na criação da máquina

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.

3. SSH: entrar com chave, nunca com senha

3.1 Gerar o par de chaves

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 -t ed25519 -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:

Terminal
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
ssh-add -l

ed25519 é o algoritmo recomendado hoje: chaves curtas, rápidas e fortes. Se um serviço antigo não aceitar, o alternativo é ssh-keygen -t rsa -b 4096.

3.2 Primeiro acesso e cópia da chave

Terminal
ssh root@203.0.113.10

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-id root@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".

3.3 Um apelido para o servidor

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).

4. Os primeiros 20 minutos no servidor

Esta é a sequência que você repete em todo servidor novo. Faça na ordem.

4.1 Atualizar o sistema

Terminal
sudo apt update
sudo apt upgrade -y
sudo reboot

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".

4.2 Criar o usuário deploy

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:

Terminal
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

A terceira linha copia o authorized_keys do root para o novo usuário, já com o dono correto — é assim que a sua chave passa a funcionar para o deploy.

Abra um segundo terminal e teste, sem fechar o primeiro:

Terminal
ssh deploy@203.0.113.10
sudo whoami

O sudo whoami deve responder root. Só depois de esse teste passar você pode mexer na configuração do SSH.

4.3 Fechar o SSH

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
sudo nano /etc/ssh/sshd_config.d/00-weblab.conf

/etc/ssh/sshd_config.d/00-weblab.conf

Texto
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
Terminal
sudo sshd -t                    # testa a sintaxe; sem saída = tudo certo
sudo systemctl restart ssh

⚠️ 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.

4.4 Firewall com ufw

O ufw é a interface amigável do firewall do Linux. A ordem dos comandos é vital:

Terminal
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status numbered

Se você rodar ufw enable antes 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.

Para ver quem está batendo na porta:

Terminal
sudo journalctl -u ssh --since "1 hour ago" | grep -c "Invalid user"

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.

4.5 Duas proteções que custam dois comandos

Terminal
sudo apt install -y unattended-upgrades fail2ban
sudo dpkg-reconfigure --priority=low unattended-upgrades
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd

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:

bash sudo journalctl -u ssh --since "10 minutes ago" | grep -E "Invalid user|Failed password" | head -20 sudo journalctl -u ssh --since "1 hour ago" | grep "Invalid user" | awk '{print $NF}' | sort | uniq -c | sort -rn | head

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?

5. Node 22 no servidor

Duas formas, e a escolha muda a vida do §8.

5.1 NodeSource (recomendado em servidor)

Terminal
curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs
node -v
npm -v
which node

O which node deve responder /usr/bin/node. Guarde esse caminho: é ele que a unidade systemd do §8.3 vai usar.

5.2 nvm (e por que ele atrapalha aqui)

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.

5.3 O npm global e o sudo

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.

6. MySQL 8

6.1 Instalar e endurecer

Terminal
sudo apt install -y mysql-server
sudo systemctl status mysql
sudo mysql_secure_installation

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).

6.2 Banco e usuário do projeto

Nunca conecte a aplicação como root. Um usuário por projeto, com acesso só ao banco daquele projeto:

Terminal
sudo mysql
SQL
CREATE DATABASE unieventos
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_0900_ai_ci;

CREATE USER 'unieventos'@'localhost'
  IDENTIFIED BY 'troque-por-uma-senha-longa-e-aleatoria';

GRANT ALL PRIVILEGES ON unieventos.* TO 'unieventos'@'localhost';
FLUSH PRIVILEGES;

SHOW GRANTS FOR 'unieventos'@'localhost';
SELECT user, host, plugin FROM mysql.user;

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.

Teste com o usuário novo e saia:

Terminal
mysql -u unieventos -p unieventos -e "SELECT DATABASE(), USER();"

6.3 O banco não pode estar na internet

Por padrão, no Ubuntu, o MySQL escuta só em 127.0.0.1 — e é assim que deve ficar. Confirme:

Terminal
sudo ss -tlnp | grep 3306
grep -n "^bind-address" /etc/mysql/mysql.conf.d/mysqld.cnf

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.

Terminal
ssh -L 3307:127.0.0.1:3306 meuvps
mysql -h 127.0.0.1 -P 3307 -u unieventos -p unieventos

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.

7. nginx: servir arquivos e ser proxy reverso

7.1 Instalar

Terminal
sudo apt install -y nginx
sudo ufw allow 'Nginx Full'
sudo systemctl status nginx
curl -I http://localhost

O curl deve responder HTTP/1.1 200 OK com Server: nginx. Abrindo o IP no navegador, aparece a página de boas-vindas.

A estrutura de arquivos do Ubuntu:

Caminho Para que serve
/etc/nginx/nginx.conf configuração global; inclui as duas pastas abaixo
/etc/nginx/sites-available/ um arquivo por site (habilitado ou não)
/etc/nginx/sites-enabled/ links simbólicos para os sites que estão no ar
/var/log/nginx/access.log e error.log onde você descobre o que aconteceu

7.2 Um site estático

Crie a pasta que vai receber os arquivos, com dono deploy — assim o rsync do §10 funciona sem sudo:

Terminal
sudo mkdir -p /var/www/unieventos-web
sudo chown -R deploy:deploy /var/www/unieventos-web

/etc/nginx/sites-available/eventos.seudominio.dev

nginx
server {
    listen 80;
    listen [::]:80;
    server_name eventos.seudominio.dev;

    root /var/www/unieventos-web;
    index index.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)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;
}
Terminal
sudo ln -s /etc/nginx/sites-available/eventos.seudominio.dev /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

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.

7.3 Proxy reverso para a API

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 {
    listen 80;
    listen [::]:80;
    server_name api.seudominio.dev;

    # Corpo máximo aceito (upload de imagem de evento, por exemplo).
    client_max_body_size 5m;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        # Sem estas quatro linhas, a API só enxerga o nginx.
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 5s;
        proxy_read_timeout    60s;
    }
}

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:

bash curl -H "Host: api.seudominio.dev" http://203.0.113.10/api/saude curl -H "Host: nome-inexistente.exemplo" -I http://203.0.113.10/

O primeiro chega à API; o segundo cai no site padrão. É o mesmo mecanismo que permite hospedar dez projetos em um IP só.

7.4 O bloco padrão

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:

/etc/nginx/sites-available/000-catch-all

nginx
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 444;
}

O código 444 é uma extensão do nginx: fecha a conexão sem responder nada. É a resposta certa para requisição sem Host válido.

8. Manter o processo vivo

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.

8.1 pm2: primeiros comandos

Terminal
sudo npm install -g pm2
pm2 --version

cd ~/apps/unieventos-api
pm2 start src/server.js --name unieventos-api
pm2 ls
pm2 logs unieventos-api --lines 50
pm2 restart unieventos-api
pm2 monit

8.2 Sobreviver ao reboot

Esta é a parte que quase todo mundo esquece — e descobre no primeiro reinício, com o site fora do ar:

Terminal
pm2 startup

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
pm2 save

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.

8.3 Arquivo de configuração do pm2

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.cjs
module.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',
    },
  ],
};
Terminal
mkdir -p ~/logs
pm2 start ecosystem.config.cjs
pm2 save

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.

8.4 A alternativa nativa: systemd

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 UniEventos
After=network.target mysql.service

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/apps/unieventos-api
EnvironmentFile=/home/deploy/apps/unieventos-api/.env
ExecStart=/usr/bin/node src/server.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
Terminal
sudo systemctl daemon-reload
sudo systemctl enable --now unieventos-api
sudo systemctl status unieventos-api
sudo journalctl -u unieventos-api -f

Três detalhes que derrubam a primeira tentativa:

  • ExecStart exige caminho absoluto. node src/server.js falha com status=203/EXEC; use /usr/bin/node (§5.1).
  • EnvironmentFile não é shell. Ele lê linhas CHAVE=valor literais: nada de export, e as aspas viram parte do valor.
  • daemon-reload depois de editar. Sem ele, o systemd continua usando a versão antiga do arquivo e você jura que a edição não fez efeito.
Critério pm2 systemd
Instalação pacote npm global já vem no sistema
Configuração ecosystem.config.cjs no repositório arquivo .service como root
Logs pm2 logs (arquivos em ~/.pm2/logs) journalctl -u <nome>
Recarga sem queda pm2 reload (com várias instâncias) systemctl restart derruba por instantes

9. HTTPS de verdade: certbot --nginx

No Capítulo 04 você viu a teoria do ACME e viu plataformas emitindo certificado por você. Agora é você.

Antes de rodar qualquer coisa, três pré-condições — todas verificáveis:

  1. O DNS já resolve os nomes para o IP do servidor: dig +short api.seudominio.dev @1.1.1.1.
  2. A porta 80 está aberta e chegando ao nginx: curl -I http://api.seudominio.dev.
  3. Existe um server com server_name exatamente igual ao nome que você vai pedir.
Terminal
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d eventos.seudominio.dev -d api.seudominio.dev

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
sudo certbot certificates
sudo nginx -t
curl -I http://api.seudominio.dev     # 301 para https
curl -I https://api.seudominio.dev    # 200

9.1 Renovação automática

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:

Terminal
systemctl list-timers | grep certbot
sudo certbot renew --dry-run

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.

10. Publicando com rsync

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.

Terminal
npm run build
rsync -avz --delete --dry-run dist/ meuvps:/var/www/unieventos-web/
rsync -avz --delete           dist/ meuvps:/var/www/unieventos-web/

As opções, uma a uma:

Opção O que faz
-a (archive) 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 pasta dist 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.

Outras variações úteis:

Terminal
rsync -avz --exclude '.git' --exclude 'node_modules' ./ meuvps:~/apps/unieventos-api/
rsync -avz -e "ssh -p 2222" dist/ meuvps:/var/www/unieventos-web/
rsync -avz meuvps:~/backups/unieventos.sql ./

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.

11.1 O que cada grupo recebe

Recurso Valor (grupo N) Exemplo (grupo 3)
Endereço público https://ivanpires.dev/dsw/gN/ https://ivanpires.dev/dsw/g3/
Acesso ssh gN@ivanpires.dev ssh g3@ivanpires.dev
Pastas ~/frontend e ~/backend as mesmas
Porta da API e serviço 350N · dsw-gN 3503 · dsw-g3
Banco MySQL db_gN db_g3

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/frontend
location /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 loopback
location /dsw/g3/api/ {
    proxy_pass http://127.0.0.1:3503/api/;
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-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=simple
User=g3
Group=g3
WorkingDirectory=/home/g3/backend
EnvironmentFile=/home/g3/backend/.env
ExecStart=/usr/bin/node src/server.js
Restart=on-failure
RestartSec=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.

11.3 O ciclo de trabalho do grupo

Do lado da sua máquina, para publicar o front:

Terminal
npm run build
rsync -avz --delete dist/ g3@ivanpires.dev:~/frontend/

Do lado do servidor, para atualizar a API:

Terminal
ssh g3@ivanpires.dev
cd ~/backend
git pull
npm ci --omit=dev
sudo systemctl restart dsw-g3
systemctl status dsw-g3 --no-pager
journalctl -u dsw-g3 -n 50 --no-pager

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.

11.4 O detalhe que mais quebra: o subcaminho

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';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  // O site é servido em https://ivanpires.dev/dsw/g3/, não na raiz.
  base: '/dsw/g3/',
});

E o vue-router precisa saber do mesmo prefixo:

src/router/index.js

JavaScript
import { createRouter, createWebHistory } from 'vue-router';
import rotas from './rotas.js';

export default createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: rotas,
});

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.

🚀 Passo a passo — o UniEventos no seu VPS

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.

Passo 1 — crie a máquina e entre

Ubuntu Server 24.04 LTS, plano mínimo, chave SSH adicionada na criação se o painel permitir.

Terminal
ssh root@203.0.113.10
apt update && apt upgrade -y

Passo 2 — usuário deploy e chave

Terminal
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

Em outro terminal, valide antes de continuar:

Terminal
ssh deploy@203.0.113.10 'sudo whoami'

Resultado esperado: root. Acrescente o apelido meuvps ao seu ~/.ssh/config (§3.3).

Passo 3 — feche o SSH e ligue o firewall

Terminal
ssh meuvps
sudo tee /etc/ssh/sshd_config.d/00-weblab.conf > /dev/null <<'FIM'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
FIM
sudo sshd -t && sudo systemctl restart ssh
sudo sshd -T | grep -i passwordauthentication

sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status

Passo 4 — aponte o DNS

No painel de DNS do seu domínio, dois registros A com TTL 300:

Texto
Tipo: A    Nome: eventos    Valor: 203.0.113.10
Tipo: A    Nome: api        Valor: 203.0.113.10

Confirme antes de seguir — o certbot do Passo 11 depende disso:

Terminal
dig +short eventos.seudominio.dev @1.1.1.1
dig +short api.seudominio.dev @1.1.1.1

Passo 5 — Node, MySQL e nginx

Terminal
curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs mysql-server nginx
sudo ufw allow 'Nginx Full'
node -v && mysql --version && nginx -v
sudo mysql_secure_installation

Passo 6 — banco e usuário

Terminal
sudo mysql
SQL
CREATE DATABASE unieventos CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE USER 'unieventos'@'localhost' IDENTIFIED BY 'coloque-uma-senha-longa-aqui';
GRANT ALL PRIVILEGES ON unieventos.* TO 'unieventos'@'localhost';
FLUSH PRIVILEGES;

Passo 7 — código e .env da API

Terminal
mkdir -p ~/apps ~/logs
cd ~/apps
git clone https://github.com/seu-usuario/unieventos-api.git
cd unieventos-api
npm ci --omit=dev
nano .env
chmod 600 .env
npm run migrar

~/apps/unieventos-api/.env

Texto
NODE_ENV=production
HOST=127.0.0.1
PORT=3000
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=unieventos
DB_PASSWORD=coloque-uma-senha-longa-aqui
DB_NAME=unieventos
CORS_ORIGENS=https://eventos.seudominio.dev

Teste antes de envolver o supervisor:

Terminal
node --env-file=.env src/server.js

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.

Passo 8 — pm2

Terminal
sudo npm install -g pm2
cd ~/apps/unieventos-api
pm2 start ecosystem.config.cjs
pm2 ls
pm2 logs unieventos-api --lines 20
pm2 startup           # copie e cole a linha 'sudo env PATH=...' que ele imprimir
pm2 save

Passo 9 — nginx

Crie os dois arquivos da §7.2 e da §7.3, com os seus nomes de domínio, e habilite:

Terminal
sudo mkdir -p /var/www/unieventos-web
sudo chown -R deploy:deploy /var/www/unieventos-web
sudo ln -s /etc/nginx/sites-available/eventos.seudominio.dev /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/api.seudominio.dev /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
curl -s http://api.seudominio.dev/api/saude

Passo 10 — publique o front

Na sua máquina, com VITE_API_URL=https://api.seudominio.dev no .env.production do projeto:

Terminal
npm run build
rsync -avz --delete --dry-run dist/ meuvps:/var/www/unieventos-web/
rsync -avz --delete           dist/ meuvps:/var/www/unieventos-web/
curl -I http://eventos.seudominio.dev

Passo 11 — HTTPS

Terminal
ssh meuvps
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d eventos.seudominio.dev -d api.seudominio.dev
sudo certbot renew --dry-run
systemctl list-timers | grep certbot

Passo 12 — prove que sobrevive ao reboot

Terminal
sudo reboot

Espere um minuto e, da sua máquina:

Terminal
curl -s https://api.seudominio.dev/api/saude
curl -I https://eventos.seudominio.dev

Se a API não responder, o pm2 save ou o pm2 startup não foram feitos (§8.2). Volte, refaça, reinicie de novo.

Como conferir

Terminal
curl -I https://eventos.seudominio.dev
curl -s https://api.seudominio.dev/api/saude
curl -I http://api.seudominio.dev
ssh meuvps 'pm2 ls; sudo ss -tlnp | grep -E ":80|:443|:3000|:3306"'

Resultado esperado:

  • o primeiro devolve HTTP/2 200 com server: nginx;
  • o segundo devolve {"status":"ok"} com ambiente igual a production;
  • o terceiro devolve 301 com location: https://api.seudominio.dev/;
  • o pm2 ls mostra unieventos-api com status online e restarts igual a 0 ou 1;
  • o ss 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:3306nenhum dos dois últimos em 0.0.0.0;
  • no navegador, o site lista eventos vindos da API, com cadeado e sem erro no console.

🧪 Laboratório

Nível A — Fixação

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?

Nível B — Aplicação

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.

Nível C — Desafio

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.

🏆 Desafios

⭐ O diário do servidor

segurancaterminalinvestigacaodeploy

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
  1. sudo journalctl -u ssh --since "24 hours ago" é a fonte; grep, awk '{print $NF}', sort | uniq -c | sort -rn | head fazem a contagem.
  2. Para os usuários tentados, procure as linhas Invalid user <nome> from <ip> — o nome é o penúltimo campo antes de from.
  3. whois <ip> | grep -i -E "country|netname" identifica a origem; muitos IPs pertencem a provedores de nuvem, não a "hackers em porões".
  4. 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.
⭐⭐

⭐⭐ Nota A no SSL Labs

httpsnginxsegurancaperformance

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
  1. 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.
  2. 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).
  3. 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.
  4. ssl_stapling on; e ssl_session_cache shared:SSL:10m; melhoram desempenho; confirme o efeito no próprio relatório do SSL Labs.
⭐⭐⭐

⭐⭐⭐ Publicação sem derrubar o site

deploynginxnodeterminal

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
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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
  1. Cinco registros A para o mesmo IP (Capítulo 04 §4.4). Confirme todos com dig +short … @1.1.1.1 antes 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.
  2. 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.
  3. 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.
  4. 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?
  5. 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.

🐛 Erros comuns

Sintoma Causa Solução
Permission denied (publickey) ao conectar chave não instalada no usuário certo, ou permissões erradas em ~/.ssh ssh-copy-id; no servidor, chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys; diagnostique com ssh -v
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! o servidor foi recriado e tem outra chave de host confirme que foi você quem recriou e rode ssh-keygen -R 203.0.113.10
Perdeu o acesso logo depois de sudo ufw enable firewall ligado sem liberar o OpenSSH antes console de emergência no painel do provedor; sudo ufw allow OpenSSH
PasswordAuthentication no não faz efeito um 50-cloud-init.conf em sshd_config.d vem antes e vence renomeie o seu arquivo para 00-…; confira com sudo sshd -T \| grep -i password
nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use) outro processo (Apache, outro nginx) já ocupa a porta 80 sudo ss -tlnp \| grep :80; remova o Apache ou pare o processo
nginx: [emerg] conflicting server name "api.seudominio.dev" dois arquivos habilitados com o mesmo server_name apague o link duplicado em sites-enabled e recarregue
502 Bad Gateway e, no error.log, connect() failed (111: Connection refused) while connecting to upstream o processo Node não está rodando, ou está em outra porta pm2 ls; sudo ss -tlnp \| grep 3000; compare com o proxy_pass
403 Forbidden em site estático e, no error.log, Permission denied o usuário www-data não consegue atravessar as pastas até o arquivo publique em /var/www/..., não em /home/deploy/...; chmod 755 nas pastas
ERROR 1045 (28000): Access denied for user 'unieventos'@'localhost' (using password: YES) senha errada no .env, ou usuário criado com outro host SELECT user, host FROM mysql.user;; recrie com ALTER USER … IDENTIFIED BY …
API: Error: connect ECONNREFUSED 127.0.0.1:3306 MySQL parado, ou DB_HOST apontando para o lugar errado sudo systemctl status mysql; DB_HOST=127.0.0.1
Serviço systemd não sobe: status=203/EXEC ExecStart sem caminho absoluto, ou Node instalado via nvm which node e use o caminho completo (/usr/bin/node)
Editou o .service e nada mudou o systemd usa a cópia carregada em memória sudo systemctl daemon-reload e depois restart
Depois do reboot, o site voltou mas a API não faltou pm2 startup (a linha sudo env PATH=…) ou pm2 save refaça os dois e teste com sudo reboot
certbot: Challenge failed for domain … Invalid response … 404 o DNS ainda não resolve para este servidor, ou a porta 80 está fechada dig +short nome @1.1.1.1; sudo ufw status; curl -I http://nome
413 Request Entity Too Large ao enviar imagem limite padrão de corpo do nginx (1 MB) client_max_body_size 5m; no server e reload
rsync apagou arquivos que você queria manter --delete sincroniza destruindo o que não está na origem use --dry-run antes; e nunca --delete numa pasta que recebe uploads

🏠 Para praticar depois da aula (1 h)

No seu projeto autoral (ou no projeto do grupo, se você tem acesso ao laboratório da turma):

  1. Publique o front e a API no servidor, seguindo o Passo a passo (ou a §11, se estiver usando gN@ivanpires.dev).
  2. 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.
  3. 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.
  4. 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.

✅ Está no ar quando…

  • [ ] 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.

📚 Para aprofundar

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.

🎯 Objetivos de aprendizagem📋 Pré-requisitos🗺️ Roteiro1. Por que um servidor próprio — e quando não vale a pena1.1 O que você ganha1.2 O que você assume1.3 Como escolher2. Escolhendo e criando o VPS2.1 Provedores2.2 Na criação da máquina3. SSH: entrar com chave, nunca com senha3.1 Gerar o par de chaves3.2 Primeiro acesso e cópia da chave3.3 Um apelido para o servidor4. Os primeiros 20 minutos no servidor4.1 Atualizar o sistema4.2 Criar o usuário deploy4.3 Fechar o SSH4.4 Firewall com ufw4.5 Duas proteções que custam dois comandos5. Node 22 no servidor5.1 NodeSource (recomendado em servidor)5.2 nvm (e por que ele atrapalha aqui)5.3 O npm global e o sudo6. MySQL 86.1 Instalar e endurecer6.2 Banco e usuário do projeto6.3 O banco não pode estar na internet7. nginx: servir arquivos e ser proxy reverso7.1 Instalar7.2 Um site estático7.3 Proxy reverso para a API7.4 O bloco padrão8. Manter o processo vivo8.1 pm2: primeiros comandos8.2 Sobreviver ao reboot8.3 Arquivo de configuração do pm28.4 A alternativa nativa: systemd9. HTTPS de verdade: certbot --nginx9.1 Renovação automática10. Publicando com rsync11. Estudo de caso: o laboratório da turma em ivanpires.dev/dsw/gN/11.1 O que cada grupo recebe11.2 Como o professor montou isso (e você repetirá no seu VPS)11.3 O ciclo de trabalho do grupo11.4 O detalhe que mais quebra: o subcaminho🚀 Passo a passo — o UniEventos no seu VPSPasso 1 — crie a máquina e entrePasso 2 — usuário deploy e chavePasso 3 — feche o SSH e ligue o firewallPasso 4 — aponte o DNSPasso 5 — Node, MySQL e nginxPasso 6 — banco e usuárioPasso 7 — código e .env da APIPasso 8 — pm2Passo 9 — nginxPasso 10 — publique o frontPasso 11 — HTTPSPasso 12 — prove que sobrevive ao rebootComo conferir🧪 LaboratórioNível A — FixaçãoNível B — AplicaçãoNível C — Desafio🏆 Desafios⭐ O diário do servidor⭐⭐ Nota A no SSL Labs⭐⭐⭐ Publicação sem derrubar o site🔥 Boss — Os três projetos no ar, com HTTPS, em subdomínios🐛 Erros comuns🏠 Para praticar depois da aula (1 h)✅ Está no ar quando…📚 Para aprofundar
DeployUnidade 3 · Infraestrutura, automação e qualidade

Capítulo 07 — Docker para desenvolvedores web

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] 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.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min O problema, os quatro conceitos (imagem, contêiner, volume, rede), instalação e primeiros comandos
2 50 min Dockerfile da API linha a linha, .dockerignore, multi-stage para o site Vite + nginx
3 50 min docker compose com API + MySQL, publicação no GHCR e execução no VPS

1. O problema que o Docker resolve

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.

Contêiner não é máquina virtual

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.

Texto
   Máquina virtual                       Contêiner
┌─────────────────────┐            ┌─────────────────────┐
│ app  │ app  │ app   │            │ app  │ app  │ app   │
│ libs │ libs │ libs  │            │ libs │ libs │ libs  │
│ SO   │ SO   │ SO    │  ← kernel  ├──────┴──────┴───────┤
├──────┴──────┴───────┤    por VM  │   Docker Engine     │
│     hipervisor      │            ├─────────────────────┤
├─────────────────────┤            │  kernel Linux (um)  │
│  kernel do host     │            ├─────────────────────┤
├─────────────────────┤            │      hardware       │
│      hardware       │            └─────────────────────┘
└─────────────────────┘

🧠 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.

2. Imagem, contêiner, volume e rede

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:

  1. 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.
  2. 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.
  3. 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:3306localhost dentro de um contêiner é o próprio contêiner. Esse detalhe causa o erro mais comum do capítulo (§🐛).

Camadas e cache

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.

3. Instalando o Docker

Dois produtos, o mesmo motor:

  • 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.

Linux (Ubuntu/Debian) e VPS

O script oficial de conveniência configura o repositório da Docker e instala o Engine, a CLI e o plugin do Compose:

Terminal
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

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
sudo usermod -aG docker $USER
newgrp docker          # aplica o grupo na sessão atual (ou saia e entre de novo)
docker --version
docker compose version

⚠️ 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".

Confirmando a instalação

Terminal
docker run hello-world
Texto
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.

4. Primeiros comandos

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
docker run -d --name teste-nginx -p 8080:80 nginx:1.28-alpine

# lista os contêineres em execução (com -a, também os parados)
docker ps

# acompanha os logs (Ctrl+C para sair — o contêiner continua rodando)
docker logs -f teste-nginx

# abre um shell DENTRO do contêiner (-i interativo, -t com terminal)
docker exec -it teste-nginx sh

Dentro do shell, olhe em volta e saia:

Terminal
ls /usr/share/nginx/html     # os arquivos que o nginx está servindo
ps aux                       # só o nginx: um processo por contêiner
exit

Abra http://localhost:8080 no navegador — a página de boas-vindas do nginx vem do contêiner. Agora pare e limpe:

Terminal
docker stop teste-nginx      # envia SIGTERM; depois de 10 s, SIGKILL
docker rm teste-nginx        # remove o contêiner (parado)
docker images                # imagens baixadas/construídas, com tamanho
docker rmi nginx: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

💡 Dica docker run cria 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.

5. Dockerfile de uma API Node

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)
FROM node:22-alpine

# 2. Metadado que liga a imagem ao repositório no GitHub (aparece no GHCR)
LABEL org.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ápidas
ENV NODE_ENV=production

# 4. Diretório de trabalho dentro da imagem; todas as instruções seguintes rodam aqui
WORKDIR /app

# 5. Copia SÓ os manifestos primeiro: se eles não mudaram, a camada do npm ci vem do cache
COPY package.json package-lock.json ./

# 6. Instala exatamente o que está no lockfile, sem devDependencies (vitest, eslint, supertest)
RUN npm ci --omit=dev

# 7. Agora o código. --chown entrega os arquivos ao usuário "node", que já existe na imagem
COPY --chown=node:node . .

# 8. A partir daqui nada roda como root — se a API for invadida, o invasor não é root
USER node

# 9. Documenta a porta (não abre nada sozinho; quem abre é o -p do run ou o ports: do compose)
EXPOSE 3000

# 10. Comando inicial, na forma exec (array): o node vira o processo principal e recebe sinais
CMD ["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=devci 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.

Encerramento limpo

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(...)
function encerrar(sinal) {
  console.log(`${sinal} recebido — parando de aceitar conexões`)
  servidor.close(() => process.exit(0))          // termina as requisições em andamento e sai
  setTimeout(() => 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.

.dockerignore

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:

Texto
node_modules
npm-debug.log
.env
.env.*
!.env.example
serviceAccountKey.json
.git
.github
test
coverage
compose*.yaml
Dockerfile
README.md

⚠️ 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.

Construindo e rodando

Terminal
cd unieventos-api
docker build -t unieventos-api:dev .
docker images unieventos-api

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:

Terminal
docker run --rm --name api-teste -p 3000:3000 \
  --env-file .env \
  -e DB_HOST=host.docker.internal \
  --add-host=host.docker.internal:host-gateway \
  unieventos-api:dev

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.

6. Site Vite servido por nginx (multi-stage)

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) ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

# VITE_* é embutida no JavaScript em tempo de BUILD — por isso é um ARG, não uma variável de execução
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build

# ---- Estágio 2: servir o resultado (só nginx + arquivos estáticos) ----
FROM nginx:1.28-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
nginx
# unieventos-web/nginx.conf
server {
  listen 80;
  server_name _;
  root /usr/share/nginx/html;
  index index.html;

  # SPA: qualquer rota que não seja um arquivo real devolve o index.html, e o Vue Router assume
  location / {
    try_files $uri $uri/ /index.html;
  }

  # os arquivos em /assets/ têm hash no nome; podem ficar em cache por muito tempo
  location /assets/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
}
Terminal
cd unieventos-web
docker build --build-arg VITE_API_URL=https://api.seudominio.dev -t unieventos-web:dev .
docker run -d --rm --name web-teste -p 8080:80 unieventos-web:dev
docker images unieventos-web

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.

7. docker compose: API + MySQL

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.

compose.yaml completo

YAML
# unieventos-api/compose.yaml — ambiente de desenvolvimento: API + MySQL
services:
  api:
    build: .                                   # constrói a partir do Dockerfile desta pasta
    image: ghcr.io/seu-usuario/unieventos-api:dev
    ports:
      - "3000:3000"
    env_file: .env                             # todas as variáveis do .env entram no contêiner
    environment:
      DB_HOST: db                              # sobrescreve o .env: aqui o MySQL se chama "db"
      DB_PORT: 3306
    depends_on:
      db:
        condition: service_healthy             # só sobe quando o healthcheck do banco passar
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s
    init: true                                 # PID 1 minimalista que encaminha sinais ao node
    restart: unless-stopped                    # volta sozinho depois de um reboot do host

  db:
    image: mysql:8.4
    environment:
      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/DBeaver
    volumes:
      - dados-mysql:/var/lib/mysql             # os dados sobrevivem a down/up e a novas imagens
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s
    restart: unless-stopped

volumes:
  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 + environmentenv_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_USER nã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.

Os comandos do dia a dia

Terminal
docker compose up -d --build     # constrói a imagem da API (se mudou) e sobe tudo em segundo plano
docker compose ps                # estado de cada serviço, inclusive o healthcheck
docker compose logs -f api       # logs só da API (sem o nome: de todos)
docker compose run --rm api npm run migrar   # comando avulso em um contêiner descartável
docker compose exec db mysql -u root -p unieventos   # cliente MySQL dentro do contêiner do banco
docker compose restart api       # reinicia só a API
docker compose down              # para e remove contêineres e rede; volumes ficam
docker compose down -v           # idem, e APAGA os volumes (os dados do banco)

⚠️ Atenção docker 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.

Variáveis: quem vence quem

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_fileenvironment-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).

Token e login

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:

Terminal
export GHCR_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
echo "$GHCR_TOKEN" | docker login ghcr.io -u seu-usuario --password-stdin

Nome, tag e push

No GHCR o nome da imagem é ghcr.io/<usuário>/<imagem>:<tag>, tudo em minúsculas:

Terminal
docker build -t ghcr.io/seu-usuario/unieventos-api:1.0.0 .
docker tag ghcr.io/seu-usuario/unieventos-api:1.0.0 ghcr.io/seu-usuario/unieventos-api:latest
docker push ghcr.io/seu-usuario/unieventos-api:1.0.0
docker push ghcr.io/seu-usuario/unieventos-api:latest

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 .

9. Boas práticas

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
cd unieventos-api
docker build -t unieventos-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.

Passo 2 — compose.yaml e .env

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 .gitignore e no .dockerignore.

Passo 3 — subir tudo local

Terminal
docker compose up -d --build
docker compose ps

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:

Terminal
docker compose run --rm api npm run migrar
docker compose logs -f api

Em outro terminal:

Terminal
curl http://localhost:3000/health
curl http://localhost:3000/api/eventos

Passo 4 — provar que os dados persistem

Terminal
docker compose down
docker compose up -d
curl http://localhost:3000/api/eventos

Os eventos continuam lá: o diretório /var/lib/mysql está no volume dados-mysql, não no contêiner. Confira com docker volume ls.

Passo 5 — publicar a imagem

Terminal
echo "$GHCR_TOKEN" | docker login ghcr.io -u seu-usuario --password-stdin
docker build -t ghcr.io/seu-usuario/unieventos-api:1.0.0 .
docker push ghcr.io/seu-usuario/unieventos-api:1.0.0

(No Mac ARM, use o docker buildx build --platform linux/amd64 --push da §8.) Torne o pacote público na página do pacote no GitHub.

Passo 6 — preparar o VPS

É 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:

Terminal
ssh meuvps
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
newgrp docker

Pare a versão em pm2 do Capítulo 06 — a API e o MySQL do sistema vão ser substituídos pelos contêineres:

Terminal
pm2 stop unieventos-api
pm2 delete unieventos-api
pm2 save
sudo systemctl stop mysql
sudo systemctl disable mysql

(Se você tinha dados no MySQL do VPS, exporte antes com mysqldump -u root -p unieventos > unieventos-backup.sql — o Capítulo 08 mostra como importar.)

Passo 7 — compose.prod.yaml no VPS

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:

YAML
# /srv/unieventos-api/compose.prod.yaml
services:
  api:
    image: ghcr.io/seu-usuario/unieventos-api:1.0.0
    ports:
      - "127.0.0.1:3000:3000"
    env_file: .env
    environment:
      DB_HOST: db
      DB_PORT: 3306
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s
    init: true
    restart: unless-stopped

  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_USER: ${DB_USER}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - dados-mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s
    restart: unless-stopped

volumes:
  dados-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".
sudo mkdir -p /srv/unieventos-api
sudo chown deploy:deploy /srv/unieventos-api
cd /srv/unieventos-api
nano .env
chmod 600 .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.

Passo 8 — subir no VPS

Terminal
docker compose -f compose.prod.yaml pull
docker compose -f compose.prod.yaml up -d
docker compose -f compose.prod.yaml ps
docker compose -f compose.prod.yaml run --rm api npm run migrar

Se você exportou dados no Passo 6, importe agora:

Terminal
# 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
docker compose -f compose.prod.yaml exec -T db \
  mysql -u root -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.

Como conferir

  1. No VPS: docker compose -f compose.prod.yaml ps mostra api e db com healthy.
  2. Da sua máquina: curl https://api.seudominio.dev/health responde {"status":"ok"} e curl https://api.seudominio.dev/api/eventos devolve a lista.
  3. docker compose -f compose.prod.yaml logs --tail 20 api mostra as requisições que você acabou de fazer.
  4. 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.

🧪 Laboratório

Nível A — Fixação

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.

Nível B — Aplicação

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.

Nível C — Desafio

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.

🏆 Desafios

⭐ Imagem abaixo de 150 MB

dockerperformanceinvestigacao

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
  1. docker history --no-trunc nome:tag mostra o tamanho de cada camada; comece pela maior.
  2. 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.
  3. Confira se test/, coverage/, .git/ e docs/ estão no .dockerignoredocker build mostra o tamanho do contexto enviado na primeira linha.
  4. 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.
⭐⭐

⭐⭐ Café Cerrado em contêiner

dockerexpressprojeto

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
  1. Descubra o caminho absoluto em que a API grava o JSON dentro do contêiner (WORKDIR + caminho relativo).
  2. Um volume nomeado novo é criado como root. Ou você cria o diretório e faz chown node:node no Dockerfile antes de declarar o VOLUME, ou monta com user: "1000:1000" no compose.
  3. 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).
  4. Teste o cenário de "imagem nova": mude qualquer coisa no código, up -d --build, e confira que o produto criado continua.
⭐⭐⭐

⭐⭐⭐ Três contêineres, um comando, zero CORS

dockernginxvueexpress

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
  1. 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:.
  2. 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.
  3. 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.
  4. 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.

🐛 Erros comuns

Sintoma Causa Solução
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock seu usuário não está no grupo docker (ou a sessão não foi reaberta depois do usermod) sudo usermod -aG docker $USER e newgrp docker, ou saia e entre de novo
Error: connect ECONNREFUSED 127.0.0.1:3306 no log da API dentro do contêiner DB_HOST=localhost — dentro do contêiner, localhost é o próprio contêiner, e lá não há MySQL DB_HOST=db (nome do serviço no compose) via environment:
dependency failed to start: container unieventos-api-db-1 is unhealthy o healthcheck do MySQL não passou no tempo previsto (primeira inicialização lenta, senha do root ausente) docker compose logs db; aumente start_period; confira MYSQL_ROOT_PASSWORD no .env
MYSQL_USER="root", MYSQL_USER and MYSQL_PASSWORD are for configuring a regular user and cannot be used for the root user .env com DB_USER=root use um usuário dedicado (DB_USER=unieventos) e DB_ROOT_PASSWORD separado
Bind for 0.0.0.0:3306 failed: port is already allocated um MySQL nativo (ou outro contêiner) já ocupa a porta na sua máquina pare o serviço nativo (sudo systemctl stop mysql) ou mapeie "127.0.0.1:3307:3306"
invalid reference format: repository name must be lowercase usuário do GitHub com maiúsculas no nome da imagem ghcr.io/seu-usuario/unieventos-api tudo em minúsculas
exec /usr/local/bin/docker-entrypoint.sh: exec format error no VPS imagem construída em Mac ARM (linux/arm64) rodando em servidor linux/amd64 docker buildx build --platform linux/amd64 --push
denied: permission_denied: write_package no docker push token sem o escopo write:packages, ou login feito em outro usuário gere um token clássico com write:packages e refaça o docker login ghcr.io
EACCES: permission denied, open '/app/dados/produtos.json' processo roda como node, mas o diretório pertence ao root COPY --chown=node:node e/ou RUN mkdir -p /app/dados && chown node:node /app/dados antes do USER node
Mudei o .sql de inicialização e o banco não mudou scripts em /docker-entrypoint-initdb.d só rodam na primeira inicialização, com o volume vazio aplique a mudança como migration (npm run migrar) ou, em desenvolvimento, docker compose down -v
docker stop demora 10 s e a API "morre" em vez de encerrar Node como PID 1 sem tratador de SIGTERM ignora o sinal process.on('SIGTERM') no server.js e init: true no compose
A porta 3000 do VPS está aberta para a internet apesar do ufw o Docker escreve regras de firewall próprias, antes das do ufw publique como "127.0.0.1:3000:3000" e deixe o nginx ser a única porta de entrada

🏠 Para praticar depois da aula (1 h)

No repositório da API do seu projeto autoral:

  1. 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).
  2. 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.
  3. 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.
  4. 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.

✅ Está no ar quando…

  • [ ] 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.

📚 Para aprofundar

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.

DeployUnidade 3 · Infraestrutura, automação e qualidade

Capítulo 08 — Bancos de dados na nuvem

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] 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.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Por que tirar o banco do servidor; o mapa dos serviços; projeto no Supabase e as três strings de conexão
2 50 min Conectando com pg e mysql2/promise: pool, TLS, DATABASE_URL; traduzindo o schema para Postgres
3 50 min Migrations, seed, backup e restauração testada; Passo a passo e Laboratório

1. Por que tirar o banco do servidor

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:

  1. Falha única. O disco do VPS é um só. Provedores baratos usam disco local sem réplica; um problema de hardware leva o servidor inteiro.
  2. 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.
  3. 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.

2. O mapa dos serviços

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.

3. Criando o Postgres no Supabase

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.

As três strings de conexão

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.

4. Conectando do Node com pg

Instale o driver oficial do Postgres:

Terminal
cd unieventos-api
npm install pg

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 nuvem
import { readFileSync } from 'node:fs'
import pg from '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).
const certificadoDaAutoridade = readFileSync(new URL(`../../${config.DATABASE_CA}`, import.meta.url))

export const pool = new pg.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 processo
  idleTimeoutMillis: 30_000,              // devolve ao servidor conexões ociosas por 30 s
  connectionTimeoutMillis: 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. */
export async function consultar(sql, parametros = []) {
  const inicio = Date.now()
  const resultado = await pool.query(sql, parametros)
  const duracao = Date.now() - inicio
  if (duracao > 200) {
    console.warn(`consulta lenta (${duracao} ms): ${sql.trim().slice(0, 70)}`)
  }
  return resultado.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.

4.1 O certificado da autoridade

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 -p certs
# copie o arquivo baixado para certs/banco-ca.crt
ls -l certs/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.

4.2 DATABASE_URL na configuração

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 única
const esquemaDeAmbiente = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),

  // postgresql://usuario:senha@host:porta/banco
  DATABASE_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.

4.3 A variante MySQL gerenciado

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'
import mysql from 'mysql2/promise'
import { config } from '../config/index.js'

export const pool = 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(new URL('../../certs/banco-ca.pem', import.meta.url)),
    minVersion: 'TLSv1.2',
  },
  waitForConnections: true,
  connectionLimit: 10,       // equivale ao max do pg
  queueLimit: 0,             // fila ilimitada de quem espera conexão
  enableKeepAlive: true,     // evita que firewalls derrubem conexões ociosas
})

/** Executa uma consulta e devolve só as linhas. */
export async function consultar(sql, parametros = []) {
  const [linhas] = await pool.query(sql, parametros)
  return linhas
}

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.

5. Traduzindo o schema: MySQL → Postgres

Se você está saindo do MySQL, o schema precisa de ajustes. Nenhum deles é difícil; todos aparecem de uma vez na primeira migration.

MySQL Postgres Atenção
INT AUTO_INCREMENT PRIMARY KEY INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY SERIAL funciona, mas é a forma antiga
ENUM('a','b') VARCHAR(20) CHECK (col IN ('a','b')) ou um CREATE TYPE ... AS ENUM
DATETIME TIMESTAMPTZ guarde sempre com fuso; converta na apresentação
TIMESTAMP DEFAULT CURRENT_TIMESTAMP TIMESTAMPTZ NOT NULL DEFAULT now() now() respeita a transação
ENGINE=InnoDB CHARSET=utf8mb4 (nada) Postgres é transacional e UTF-8 por padrão
? como placeholder $1, $2, $3 numerados, podem repetir
resultado.insertId RETURNING * na própria consulta devolve a linha inteira, não só o id
`crase` para identificadores "aspas duplas" sem aspas, Postgres rebaixa tudo para minúsculas

A migration 0001 do UniEventos, na versão Postgres:

SQL
-- migrations/0001_criar_tabela_eventos.sql
CREATE TABLE IF NOT EXISTS eventos (
  id          INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  titulo      VARCHAR(150) NOT NULL,
  descricao   TEXT,
  categoria   VARCHAR(20) NOT NULL
              CHECK (categoria IN ('palestra', 'minicurso', 'workshop')),
  data_hora   TIMESTAMPTZ NOT NULL,
  local       VARCHAR(150) NOT NULL,
  vagas       INTEGER NOT NULL DEFAULT 0 CHECK (vagas >= 0),
  imagem_url  VARCHAR(255),
  criado_em   TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT uk_eventos_titulo UNIQUE (titulo)
);

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.

SQL
-- migrations/0002_criar_tabela_inscricoes.sql
CREATE TABLE IF NOT EXISTS inscricoes (
  id           INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  evento_id    INTEGER NOT NULL REFERENCES eventos(id) ON DELETE CASCADE,
  usuario_uid  VARCHAR(128) NOT NULL,
  criado_em    TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT uk_evento_usuario UNIQUE (evento_id, usuario_uid)
);

CREATE INDEX IF NOT EXISTS idx_inscricoes_usuario ON inscricoes (usuario_uid);

E o repositório, adaptado. É a mesma interface da Aula 13 — quem chama não percebe a troca:

JavaScript
// src/repositories/eventos.repository.postgres.js
import { pool } from '../db/pool.js'

export function criarRepositorioDeEventosPostgres() {
  return {
    async listar({ categoria = null, limite = 20, deslocamento = 0 } = {}) {
      const { rows } = await pool.query(
        `SELECT * FROM eventos
          WHERE ($1::text IS NULL OR categoria = $1)
          ORDER BY data_hora
          LIMIT $2 OFFSET $3`,
        [categoria, limite, deslocamento],
      )
      return rows
    },

    async buscarPorId(id) {
      const { rows } = await pool.query('SELECT * FROM eventos WHERE id = $1', [id])
      return rows[0] ?? null
    },

    async criar(evento) {
      const { rows } = await pool.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,
        ],
      )
      return rows[0]
    },

    async remover(id) {
      const resultado = await pool.query('DELETE FROM eventos WHERE id = $1', [id])
      return resultado.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.

6. RLS: o que muda quando você conecta por SQL

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.
ALTER TABLE eventos ENABLE ROW LEVEL SECURITY;

CREATE POLICY "eventos visíveis para todos"
  ON eventos FOR SELECT
  USING (true);

CREATE POLICY "somente autenticados criam eventos"
  ON eventos FOR INSERT
  TO authenticated
  WITH CHECK (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.

7. Migrations no banco na nuvem

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 cada
import { readdir, readFile } from 'node:fs/promises'
import { readFileSync } from 'node:fs'
import pg from 'pg'
import { config } from '../src/config/index.js'

const PASTA = new URL('../migrations/', import.meta.url)

const cliente = new pg.Client({
  connectionString: config.DATABASE_URL,
  ssl: {
    ca: readFileSync(new URL(`../${config.DATABASE_CA}`, import.meta.url)),
    rejectUnauthorized: true,
  },
})

async function migrar() {
  await cliente.connect()

  await cliente.query(`
    CREATE TABLE IF NOT EXISTS migrations_executadas (
      nome_arquivo TEXT PRIMARY KEY,
      executado_em TIMESTAMPTZ NOT NULL DEFAULT now()
    )
  `)

  const { rows } = await cliente.query('SELECT nome_arquivo FROM migrations_executadas')
  const jaExecutadas = new Set(rows.map((linha) => linha.nome_arquivo))

  const arquivos = (await readdir(PASTA)).filter((nome) => nome.endsWith('.sql')).sort()
  let aplicadas = 0

  for (const arquivo of arquivos) {
    if (jaExecutadas.has(arquivo)) {
      console.log(`pulando ${arquivo} (já aplicada)`)
      continue
    }

    const sql = await readFile(new URL(arquivo, PASTA), 'utf-8')
    console.log(`aplicando ${arquivo}...`)

    // DDL transacional: ou a migration inteira entra, ou nada entra.
    await cliente.query('BEGIN')
    try {
      await cliente.query(sql)
      await cliente.query('INSERT INTO migrations_executadas (nome_arquivo) VALUES ($1)', [arquivo])
      await cliente.query('COMMIT')
      aplicadas += 1
      console.log(`${arquivo} aplicada`)
    } catch (erro) {
      await cliente.query('ROLLBACK')
      throw new Error(`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:

  1. 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).
  2. Uma mudança por arquivo, com nome que descreve a mudança. 0005_adicionar_indice_categoria.sql conta a história no git log.
  3. Migration que apaga dado precisa de revisão de outra pessoa. DROP COLUMN em produção não tem Ctrl+Z.

Seed: dados de exemplo, sem duplicar

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 duplicar
import { pool } from '../src/db/pool.js'
import { config } from '../src/config/index.js'

const EVENTOS = [
  {
    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.',
  },
]

async function semear() {
  if (config.NODE_ENV === 'production' && process.env.PERMITIR_SEED !== 'sim') {
    throw new Error('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.
  let inseridos = 0
  for (const [posicao, evento] of EVENTOS.entries()) {
    const { rowCount } = await pool.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.

JSON
{
  "scripts": {
    "migrar": "node scripts/migrar.js",
    "semear": "node scripts/semear.js"
  }
}

8. Backup e restauração

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.

Postgres: pg_dump e psql

Terminal
# 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.

Restaurando:

Terminal
# formato texto
psql "$DATABASE_URL_DESTINO" --file=backup-unieventos.sql

# formato custom
pg_restore --dbname="$DATABASE_URL_DESTINO" --no-owner --clean --if-exists backup-unieventos.dump

MySQL: mysqldump e mysql

O comando prometido no Capítulo 07, agora com as opções que importam:

Terminal
mysqldump \
  --host=mysql-xxxx.exemplo.aivencloud.com --port=12345 \
  --user=avnadmin --password \
  --single-transaction --quick --routines --triggers \
  --ssl-mode=REQUIRED \
  unieventos > backup-unieventos.sql
  • --single-transaction tira o dump dentro de uma transação: consistente e sem travar as tabelas InnoDB (sem ele, o site fica lento durante o backup).
  • --quick transmite linha a linha em vez de carregar a tabela inteira na memória.
  • --routines --triggers incluem procedures e triggers, que ficam de fora por padrão.
  • --ssl-mode=REQUIRED recusa conexão sem TLS — em banco na nuvem, sempre.

Restaurando:

Terminal
mysql \
  --host=mysql-xxxx.exemplo.aivencloud.com --port=12345 \
  --user=avnadmin --password --ssl-mode=REQUIRED \
  unieventos < backup-unieventos.sql

Automatizando e, principalmente, testando

Terminal
#!/usr/bin/env bash
# scripts/backup.sh — dump diário com retenção de 7 dias
set -euo pipefail

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 +x scripts/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
docker run -d --rm --name pg-teste -e POSTGRES_PASSWORD=teste -p 5433:5432 postgres:17-alpine

# 2. espera aceitar conexão e restaura o dump
sleep 10
pg_restore --dbname='postgresql://postgres:teste@localhost:5433/postgres' --no-owner backup-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
docker stop pg-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.

Passo 1 — criar o projeto e guardar a URL

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
cd unieventos-api
mkdir -p certs
# salve o certificado baixado como certs/banco-ca.crt

Passo 2 — configurar a API

Terminal
npm install pg

Ajuste src/config/index.js (§4.2), crie src/db/pool.js (§4) e preencha o .env:

Terminal
# .env — local
DATABASE_URL=postgresql://postgres.SEU_REF:SUA_SENHA@aws-0-sa-east-1.pooler.supabase.com:5432/postgres
DATABASE_CA=certs/banco-ca.crt

Teste a conexão antes de qualquer outra coisa:

Terminal
node --input-type=module -e "import {pool} from './src/db/pool.js'; const r = await pool.query('select version()'); console.log(r.rows[0].version); await pool.end()"

A saída deve começar com PostgreSQL. Se der erro, vá direto para a tabela de 🐛 Erros comuns — os quatro primeiros casos cobrem 90% das falhas aqui.

Passo 3 — migrations e seed

Traduza as migrations para Postgres (§5), atualize scripts/migrar.js (§7) e rode:

Terminal
npm run migrar
npm run semear

Confira no Table Editor do Supabase: a tabela eventos com três linhas, inscricoes vazia e migrations_executadas com um registro por arquivo.

Passo 4 — apontar os repositórios e subir a API

Troque o repositório MySQL pelo de Postgres (§5) na montagem do src/app.js e suba:

Terminal
npm run dev
curl http://localhost:3000/health
curl http://localhost:3000/api/eventos

/api/eventos deve devolver os três eventos do seed, vindos da nuvem.

Passo 5 — o VPS sem MySQL

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
ssh meuvps
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
docker compose -f compose.prod.yaml exec -T db \
  mysqldump -u root -p"$DB_ROOT_PASSWORD" --single-transaction unieventos > 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:

Terminal
docker compose -f compose.prod.yaml up -d
docker compose -f compose.prod.yaml logs --tail 30 api
curl -s https://api.seudominio.dev/api/eventos | head -c 200

A API agora é sem estado: pode ser destruída e recriada sem perder nada. É exatamente o que o Capítulo 09 precisa para fazer deploy automático.

Passo 6 — backup e restauração testada

Terminal
export DATABASE_URL='postgresql://postgres.SEU_REF:SUA_SENHA@aws-0-sa-east-1.pooler.supabase.com:5432/postgres'
./scripts/backup.sh

Restaure em um Postgres descartável e compare as contagens (§8). Anote os dois números no README.md, na seção Banco de dados.

Como conferir

  1. curl https://api.seudominio.dev/api/eventos devolve os eventos, e o painel do Supabase mostra a conexão ativa em Settings → Database.
  2. docker compose -f compose.prod.yaml ps no VPS mostra um serviço só (api).
  3. npm run migrar rodado duas vezes seguidas não aplica nada na segunda.
  4. npm run semear rodado duas vezes não duplica eventos.
  5. 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.

🧪 Laboratório

Nível A — Fixação

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?

Nível B — Aplicação

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.

Nível C — Desafio

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.

🏆 Desafios

⭐ O banco que responde em 20 ms — ou em 200

banco-de-dadosperformanceinvestigacao

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
  1. Crie um segundo projeto gratuito em outra região só para a medição; apague depois.
  2. 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.
  3. 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.
  4. 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.
⭐⭐

⭐⭐ O backup que você provou que funciona

banco-de-dadosdeployseguranca

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
  1. set -euo pipefail no topo faz o script parar no primeiro erro em vez de seguir e "terminar com sucesso" sem ter feito nada.
  2. Para comparar contagens automaticamente, gere um arquivo com SELECT count(*) de cada tabela nos dois bancos e use diff.
  3. 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.
  4. 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
  1. Ordem importa: eventos antes de inscricoes, senão a chave estrangeira reclama. Ou desabilite as restrições durante a carga e reabilite no fim.
  2. 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.
  3. 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.
  4. 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.

🐛 Erros comuns

Sintoma Causa Solução
Error: connect ENETUNREACH 2600:1f1c:…:5432 a conexão direta do Supabase só tem endereço IPv6 e a sua rede é IPv4 use a string do pooler de sessão (...pooler.supabase.com:5432)
error: password authentication failed for user "postgres" senha errada, ou usuário do pooler sem o sufixo do projeto no pooler o usuário é postgres.<ref>; senha com caractere especial precisa ser codificada na URL
error: no pg_hba.conf entry for host "…", SSL off conexão aberta sem TLS passe ssl: { ca, rejectUnauthorized: true } no Pool
Error: self-signed certificate in certificate chain TLS ativo, mas sem o certificado da autoridade do provedor baixe o certificado no painel e aponte ca para ele (não desligue a verificação)
error: sorry, too many clients already soma dos max de todas as instâncias acima do limite do plano baixe o max, use o pooler e feche o pool no encerramento (pool.end())
error: prepared statement "s1" already exists pooler de transação (6543) com prepared statements use a porta 5432 (sessão) para a API que fica no ar
error: relation "eventos" does not exist migrations não aplicadas nesse banco, ou banco/schema errado na URL npm run migrar apontando para a URL certa; confira o nome do banco no fim da URL
error: syntax error at or near "AUTO_INCREMENT" SQL escrito para MySQL rodando no Postgres traduza o schema pela tabela da §5
error: could not determine data type of parameter $1 o mesmo parâmetro comparado com NULL e com uma coluna escreva o tipo: $1::text IS NULL OR categoria = $1
Error: ETIMEDOUT ao conectar em MySQL gerenciado IP não liberado na lista de permissões do provedor libere o IP do VPS e do seu computador no painel do serviço
pg_dump: error: aborting because of server version mismatch pg_dump mais antigo que o servidor instale a versão do cliente igual ou maior (postgresql-client-17) ou rode dentro de um contêiner com a versão certa
Consulta pelo supabase-js devolve data: [] sem erro RLS habilitado sem policy para aquele papel crie a policy (§6); pelo pg com a senha do banco esse caso não aparece, porque o dono ignora RLS

🏠 Para praticar depois da aula (1 h)

No repositório da API do seu projeto autoral:

  1. 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.
  2. Substitua as variáveis soltas do banco por DATABASE_URL (mais DATABASE_CA) no src/config/index.js e atualize o .env.example.
  3. 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.
  4. 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).

Guarde no seu repositório: commit + push.

✅ Está no ar quando…

  • [ ] O banco existe em um serviço gerenciado, na região mais próxima, e você guardou a senha no gerenciador de senhas.
  • [ ] src/db/pool.js usa pool (new pg.Pool ou mysql.createPool) com TLS verificado por certificado, max compatível com o plano e tratador de error.
  • [ ] DATABASE_URL está no .env, no .env.example (sem valor real) e em nenhum outro lugar do repositório.
  • [ ] npm run migrar aplica o schema em um banco vazio e não faz nada na segunda execução.
  • [ ] npm run semear popula o banco e é idempotente.
  • [ ] GET /api/eventos responde com dados do banco na nuvem, local e no VPS.
  • [ ] O compose.prod.yaml do VPS não tem mais serviço de banco nem volume de dados.
  • [ ] Existe um .sql/.dump de backup guardado fora do servidor, e você já o restaurou uma vez conferindo as contagens.
  • [ ] O README.md tem uma seção Banco de dados com serviço, região e os comandos de migrar, semear, backup e restauração.

📚 Para aprofundar

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.

DeployUnidade 3 · Infraestrutura, automação e qualidade

Capítulo 09 — CI/CD com GitHub Actions

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] 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.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min CI/CD na prática; anatomia de um workflow; o primeiro ci.yml com lint, testes e cache
2 50 min Segredos; banco em contêiner de serviço; publicação automática do site estático
3 50 min Imagem no GHCR, deploy por SSH com rollback, proteção de branch, badge e Laboratório

1. Integração e entrega contínuas

Duas siglas, dois problemas diferentes.

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:

  1. 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.
  2. 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.
  3. 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.

2. Anatomia de um workflow

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.yml
name: Exemplo

on: [push]

jobs:
  dizer-oi:
    runs-on: ubuntu-latest
    steps:
      - name: Falar com o mundo
        run: 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.

Onde cada coisa roda

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:

  1. 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.
  2. 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).

3. Workflow 1 — CI: lint e testes

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.yml
name: CI

on:
  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: true

jobs:
  verificar:
    name: Lint e testes
    runs-on: ubuntu-latest
    timeout-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: test
      DATABASE_URL: postgresql://postgres:teste@localhost:5432/unieventos_teste
      FIREBASE_PROJECT_ID: projeto-de-teste
      CORS_ORIGEM_PERMITIDA: http://localhost:5173

    steps:
      - name: Baixar o código
        uses: actions/checkout@v4

      - name: Preparar o Node 22 com cache do npm
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm

      - name: Instalar dependências do lockfile
        run: npm ci

      - name: Conferir o estilo do código
        run: npm run lint --if-present

      - name: Rodar os testes
        run: 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ção npm 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.

3.1 Testes de integração com um banco de verdade

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 arquivo
  integracao:
    name: Testes de integração com Postgres
    runs-on: ubuntu-latest
    timeout-minutes: 15

    services:
      postgres:
        image: postgres:17-alpine
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: teste
          POSTGRES_DB: unieventos_teste
        ports:
          - 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 5

    env:
      NODE_ENV: test
      DATABASE_URL: postgresql://postgres:teste@localhost:5432/unieventos_teste
      FIREBASE_PROJECT_ID: projeto-de-teste
      CORS_ORIGEM_PERMITIDA: http://localhost:5173

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm

      - run: npm ci

      - name: Criar o schema no banco do job
        run: npm run migrar

      - name: Rodar a suíte inteira
        run: 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 é remoto
const ehBancoLocal = /@(localhost|127\.0\.0\.1)[:/]/.test(config.DATABASE_URL)

export const pool = new pg.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.

3.2 Matriz: a mesma suíte em várias versões

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-latest
    strategy:
      fail-fast: false        # não cancela as outras quando uma falha
      matrix:
        node: ['22', '24']
    name: Testes no Node ${{ matrix.node }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci
      - run: npm test
        env:
          NODE_ENV: test
          DATABASE_URL: postgresql://postgres:teste@localhost:5432/unieventos_teste
          FIREBASE_PROJECT_ID: projeto-de-teste
          CORS_ORIGEM_PERMITIDA: http://localhost:5173

4. Segredos e variáveis

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á-lo
        run: |
          # CERTO: o valor vai para a variável de ambiente do processo
          curl -fsS -H "Authorization: Bearer $TOKEN" https://api.exemplo.com/status
        env:
          TOKEN: ${{ secrets.TOKEN_DA_API }}

Três regras sobre segredos:

  1. 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.
  2. Um segredo por finalidade. Uma chave SSH só para o deploy, revogável sem afetar o resto.
  3. 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ção permissions: 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.

5. Workflow 2 — publicar o site estático

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.yml
name: Publicar site

on:
  push:
    branches: [main]
  workflow_dispatch:          # botão "Run workflow" na aba Actions

permissions:
  contents: read
  pages: write                # publicar no Pages
  id-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: pages
  cancel-in-progress: false

jobs:
  construir:
    name: Construir o site
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Preparar o Pages
        id: pages
        uses: actions/configure-pages@v5

      - uses: actions/setup-node@v4
        with:
          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 Pages
        uses: actions/upload-pages-artifact@v3
        with:
          path: dist

  publicar:
    name: Publicar no Pages
    needs: construir            # só roda se o build terminou bem
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.publicacao.outputs.page_url }}
    steps:
      - name: Publicar
        id: publicacao
        uses: 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).

Alternativa: Netlify

Se o site está no Netlify (Capítulo 03), troque os dois jobs por um step só, usando a CLI oficial:

YAML
      - name: Publicar no Netlify
        run: npx --yes netlify-cli deploy --prod --dir=dist --message "commit ${{ github.sha }}"
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

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.

6. Workflow 3 — imagem no GHCR e deploy no VPS

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.yml
name: Publicar imagem e implantar

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  packages: write             # publicar no GitHub Container Registry

concurrency:
  group: deploy-producao
  cancel-in-progress: false   # nunca cancele um deploy pela metade

jobs:
  imagem:
    name: Construir e publicar a imagem
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4

      - name: Preparar o Buildx
        uses: docker/setup-buildx-action@v3

      - name: Autenticar no GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Calcular nome e tags da imagem
        id: metadados
        uses: docker/metadata-action@v5
        with:
          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 enviar
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64
          push: true
          tags: ${{ steps.metadados.outputs.tags }}
          labels: ${{ steps.metadados.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  implantar:
    name: Implantar no VPS
    needs: imagem
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment: producao
    steps:
      - name: Atualizar os contêineres por SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USUARIO }}
          key: ${{ secrets.VPS_CHAVE_SSH }}
          script: |
            set -e
            cd /srv/unieventos-api
            export TAG_IMAGEM="${{ github.sha }}"
            docker compose -f compose.prod.yaml pull
            docker compose -f compose.prod.yaml up -d
            docker image prune -f
            for tentativa in 1 2 3 4 5 6 7 8 9 10; do
              if curl -fsS http://127.0.0.1:3000/health > /dev/null; then
                echo "no ar na versão $TAG_IMAGEM"
                exit 0
              fi
              sleep 3
            done
            echo "a API não respondeu ao /health em 30 s"
            exit 1

6.1 A tag que permite voltar atrás

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:

YAML
# /srv/unieventos-api/compose.prod.yaml — trecho alterado
services:
  api:
    image: ghcr.io/seu-usuario/unieventos-api:${TAG_IMAGEM:-latest}
    ports:
      - "127.0.0.1:3000:3000"
    env_file: .env
    init: true
    restart: unless-stopped

${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:

Terminal
ssh meuvps
cd /srv/unieventos-api
TAG_IMAGEM=<sha-do-commit-anterior> docker compose -f compose.prod.yaml up -d

💡 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.

6.2 A chave SSH do robô

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 -t ed25519 -C "github actions unieventos" -f chave-deploy -N ""

# envia a chave PÚBLICA para o VPS
ssh-copy-id -i chave-deploy.pub meuvps

# mostra a chave PRIVADA para copiar (inteira, com as linhas BEGIN e END)
cat chave-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.

7. Badge e proteção de branch

Um workflow que ninguém olha não protege nada. Duas providências fazem o resultado virar consequência.

Badge no README.md — a imagem que mostra se o main está passando:

Markdown
[![CI](https://github.com/seu-usuario/unieventos-api/actions/workflows/ci.yml/badge.svg)](https://github.com/seu-usuario/unieventos-api/actions/workflows/ci.yml)

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.

8. Boas práticas

Prática Por quê Como
Menor privilégio um workflow comprometido faz menos estrago permissions: explícito em cada workflow
concurrency evita dois deploys simultâneos e economiza minutos group: por ref, cancel-in-progress só na CI
timeout-minutes um job travado queima minutos até seis horas 10 a 20 minutos em cada job
Cache de dependências corta o tempo do npm ci cache: npm no setup-node; type=gha no build da imagem
Filtros de caminho não rodar a CI da API quando só o README mudou paths: e paths-ignore: no on:
Fixar a versão da action uma action é código de terceiros que roda com os seus segredos @v4 no mínimo; em produção, o SHA completo
Atualização revisada actions e imagens envelhecem e ganham falhas Dependabot com package-ecosystem: github-actions
Aprovação manual um humano decide quando o deploy acontece environment: com required reviewers

Exemplo de filtro de caminho, útil em um repositório que guarda front e back juntos:

YAML
on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'package.json'
      - 'package-lock.json'
      - 'Dockerfile'
      - '.github/workflows/deploy.yml'

E o Dependabot mantendo as actions atualizadas por pull request (que passa pela sua própria CI antes de ser mesclado):

YAML
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly

  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly

🚀 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.

Passo 1 — a CI antes de tudo

Terminal
cd unieventos-api
mkdir -p .github/workflows

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
git switch -c ci-inicial
git add .github/workflows/ci.yml
git commit -m "ci: rodar lint e testes a cada push e pull request"
git push -u origin ci-inicial
gh pr create --fill

Abra o pull request no navegador: em segundos aparece a verificação "CI / Lint e testes" rodando. Espere ficar verde.

Passo 2 — provar que a CI pega erro

Antes de confiar na esteira, quebre-a de propósito. Na mesma branch, edite um teste para esperar o valor errado e empurre:

Terminal
git commit -am "test: quebrar um teste de propósito"
git push

O PR fica vermelho, com o log mostrando exatamente qual expectativa falhou. Esse é o momento em que a CI ganha valor. Desfaça e empurre de novo:

Terminal
git revert --no-edit HEAD
git push

Mescle o PR quando voltar ao verde.

Passo 3 — proteger o main

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.

Passo 4 — segredos do deploy

Gere a chave dedicada (§6.2) e cadastre em Settings → Secrets and variables → Actions:

Nome Tipo Conteúdo
VPS_HOST secret IP ou domínio do VPS
VPS_USUARIO secret deploy
VPS_CHAVE_SSH secret a chave privada inteira

Passo 5 — o VPS pronto para receber

No VPS, ajuste o compose.prod.yaml para usar ${TAG_IMAGEM:-latest} (§6.1) e confirme que o deploy manual ainda funciona:

Terminal
ssh meuvps
cd /srv/unieventos-api
TAG_IMAGEM=latest docker compose -f compose.prod.yaml up -d
curl -fsS http://127.0.0.1:3000/health

Se o pacote no GHCR for privado, faça um docker login ghcr.io no VPS com um token de leitura; se for público (Capítulo 07), não precisa de nada.

Passo 6 — o workflow de deploy

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.

Terminal
curl -s https://api.seudominio.dev/health

Passo 7 — o site estático

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.

Passo 8 — mudar algo de verdade

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.

Como conferir

  1. A aba Actions mostra três workflows com execuções verdes.
  2. O README.md exibe o badge da CI, e ele está verde.
  3. Um PR com teste quebrado não pode ser mesclado.
  4. Packages no GitHub lista a imagem com uma tag latest e uma tag por commit.
  5. ssh meuvps 'docker ps --format "{{.Image}}"' mostra a imagem com o SHA do último commit.
  6. Um TAG_IMAGEM=<sha anterior> docker compose up -d volta a versão anterior em segundos.

Resultado esperado: você não digita mais nenhum comando de deploy. Digita código, abre PR, mescla — e o resto acontece.

🧪 Laboratório

Nível A — Fixação

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?

Nível B — Aplicaçã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.

Nível C — Desafio

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.

🏆 Desafios

⭐ A esteira que reprova

ci-cdgithubtestes

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
  1. Para dessincronizar o lockfile, edite a versão de uma dependência no package.json sem rodar npm install.
  2. 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.
  3. A mensagem literal está no log do step que falhou; a aba Annotations da execução traz o resumo.
  4. Ao fechar um PR sem mesclar, apague também a branch remota — gh pr close --delete-branch.
⭐⭐

⭐⭐ Ambiente de revisão para cada pull request

ci-cddeploygithubprojeto

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
  1. 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.
  2. Para comentar, gh pr comment ${{ github.event.pull_request.number }} --body "..." usando o GITHUB_TOKEN com pull-requests: write.
  3. 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.
  4. O evento pull_request com types: [closed] é o gancho para a limpeza.
⭐⭐⭐

⭐⭐⭐ Banco de verdade em cada pull request

ci-cdbanco-de-dadostestesinvestigacao

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
  1. 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.
  2. Para comparar schemas antes e depois, pg_dump --schema-only nos dois momentos e diff entre os arquivos.
  3. 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()).
  4. 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/health de 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
  1. 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.
  2. if: failure() em um step faz dele um step de compensação, que só roda quando algo antes falhou. É o gancho natural do rollback.
  3. 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.
  4. 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.
  5. 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.

🐛 Erros comuns

Sintoma Causa Solução
Invalid workflow file … (Line: 12, Col: 5): Unexpected value 'run' indentação errada no YAML (tabulação ou nível trocado) reindente com 2 espaços; valide com a extensão GitHub Actions do VS Code
npm ci can only install packages when your package.json and package-lock.json … are in sync lockfile desatualizado ou não commitado npm install local, commite o package-lock.json e empurre
Dependencies lock file is not found in /home/runner/work/… cache: npm sem lockfile na raiz (monorepo) aponte cache-dependency-path: para o lockfile certo
Error: Process completed with exit code 1 sem mais nada o comando do run: falhou; a causa está nas linhas acima abra o step no log e leia de baixo para cima até a primeira linha vermelha
Error: Resource not accessible by integration GITHUB_TOKEN sem a permissão que o step precisa acrescente a permissão no bloco permissions: do workflow
denied: permission_denied: write_package no push da imagem falta packages: write ou login feito com o usuário errado permissions: packages: write e username: ${{ github.actor }}
Error: Get Pages site failed. Please verify that the repository has Pages enabled Pages não configurado como origem "GitHub Actions" Settings → Pages → Source: GitHub Actions
ssh: handshake failed: ssh: unable to authenticate chave privada incompleta no secret, ou chave pública não autorizada no VPS cole a chave inteira (com BEGIN/END); confira o authorized_keys do usuário deploy
Error: connect ECONNREFUSED 127.0.0.1:5432 no job o job começou antes de o contêiner de serviço estar pronto options: com --health-cmd e --health-retries no serviço
Deploy "funciona", mas o servidor continua na versão antiga compose reusou a imagem latest já baixada tag por commit e docker compose pull antes do up -d
exec /usr/local/bin/docker-entrypoint.sh: exec format error imagem construída para outra arquitetura platforms: linux/amd64 no docker/build-push-action@v6
O secret aparece como *** mas o script falha na autenticação segredo cadastrado com espaço ou quebra de linha a mais recadastre colando sem espaços; teste com o tamanho (echo -n "$TOKEN" \| wc -c)
A CI não roda em um PR aberto de um fork política do repositório exige aprovação para colaboradores de primeira viagem aprove a execução no botão da própria página do PR

🏠 Para praticar depois da aula (1 h)

No repositório do seu projeto autoral:

  1. 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.
  2. Proteja o main: pull request obrigatório e a verificação da CI como status obrigatório.
  3. Adicione o badge da CI na primeira linha do README.md.
  4. 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.
  5. 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.

✅ Está no ar quando…

  • [ ] .github/workflows/ci.yml roda em todo push e PR, com npm ci, lint, testes, cache e permissions explícito.
  • [ ] Um PR com teste quebrado fica vermelho e não pode ser mesclado.
  • [ ] O badge da CI aparece no README.md e reflete o estado do main.
  • [ ] O site estático é publicado sozinho a partir do main (Pages ou Netlify), com a URL da API vindo de uma variável.
  • [ ] Cada push no main da API publica no GHCR uma imagem com tag latest e tag com o SHA do commit.
  • [ ] O VPS é atualizado por SSH, sem você digitar nada, e o workflow confirma o /health antes de terminar.
  • [ ] TAG_IMAGEM=<sha anterior> docker compose -f compose.prod.yaml up -d devolve a versão anterior ao ar.
  • [ ] Nenhum segredo está no repositório; a chave SSH do deploy é dedicada e revogável.
  • [ ] Os workflows terminam em menos de 5 minutos do push até o ar.

📚 Para aprofundar

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.

DeployUnidade 3 · Infraestrutura, automação e qualidade

Capítulo 10 — Qualidade, performance e observabilidade

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] 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.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Por que medir; ESLint 9 flat config e Prettier nos dois projetos; scripts npm de qualidade
2 50 min Testes de API e de componente com Vitest; Core Web Vitals; Lighthouse no DevTools, na CLI e no CI
3 50 min Melhorias de performance com medição antes/depois; Sentry, pino, uptime, robots.txt e sitemap.xml

1. Qualidade não é opinião

"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?".

2. ESLint 9 e Prettier

2.1 O que o linter faz

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.

2.2 Na API

Terminal
cd ~/weblab/unieventos-api
npm install --save-dev eslint @eslint/js globals prettier eslint-config-prettier

unieventos-api/eslint.config.js

JavaScript
import js from '@eslint/js'
import globals from 'globals'
import prettier from 'eslint-config-prettier'

export default [
  // 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.

2.3 No front Vue

Terminal
cd ~/weblab/unieventos-web
npm install --save-dev eslint @eslint/js globals eslint-plugin-vue prettier eslint-config-prettier

unieventos-web/eslint.config.js

JavaScript
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import prettier from 'eslint-config-prettier'

export default [
  { 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.

2.4 Prettier: quem decide a forma

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.

3. Testes da API com Vitest e supertest

3.1 O que testar quando o tempo é curto

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:

Terminal
cd ~/weblab/unieventos-api
npm install --save-dev vitest supertest @vitest/coverage-v8

💡 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).

3.2 Teste de unidade

unieventos-api/src/util/paginacao.js

JavaScript
/** Recorta uma lista em páginas. Sempre devolve uma página válida, mesmo com entrada absurda. */
export function paginar(lista, pagina = 1, porPagina = 10) {
  const total = lista.length
  const paginas = Math.max(1, Math.ceil(total / porPagina))
  const atual = Math.min(Math.max(1, Number(pagina) || 1), paginas)
  const inicio = (atual - 1) * porPagina
  return { 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'

const trinta = Array.from({ length: 30 }, (_, i) => ({ id: i + 1 }))

describe('paginar', () => {
  it('devolve os 10 primeiros itens na página 1', () => {
    const r = 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', () => {
    const r = 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.

3.3 Teste de integração da rota

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.js exporte 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'
import request from 'supertest'
import { app } from '../src/app.js'

describe('GET /api/eventos', () => {
  it('responde 200, JSON e uma lista', async () => {
    const resposta = await request(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 () => {
    const resposta = await request(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.

3.4 Executando e medindo cobertura

Terminal
npx vitest run                     # roda tudo que parece teste e sai
npx vitest                         # modo interativo: reexecuta ao salvar
npx vitest run -t "paginar"        # só os testes cujo nome bate
npx vitest run --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".

4. Testes de componente com Vitest

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.

Terminal
cd ~/weblab/unieventos-web
npm install --save-dev vitest @vue/test-utils jsdom @vitest/coverage-v8

unieventos-web/vitest.config.js

JavaScript
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config.js'

export default mergeConfig(
  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(new URL('./', 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() {}
}

unieventos-web/src/components/__tests__/EventoCard.spec.js

JavaScript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import EventoCard from '../EventoCard.vue'

const vuetify = createVuetify({ components, directives })

function montar(evento) {
  return mount(EventoCard, { global: { plugins: [vuetify] }, props: { evento } })
}

describe('EventoCard', () => {
  it('mostra o título e o local do evento', () => {
    const wrapper = 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', () => {
    const wrapper = 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 () => {
    const wrapper = montar({ id: 7, titulo: 'Palestra', local: 'Auditório', vagas: 5 })
    await wrapper.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.

5. Core Web Vitals e Lighthouse

5.1 As três métricas

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ê.

5.2 Medindo de três formas

No DevTools: aba LighthouseMobileAnalyze page load. Bom para explorar, porque cada item reprovado abre com explicação e lista de arquivos.

Na linha de comando, que é o que permite guardar um relatório e comparar depois:

Terminal
npx lighthouse@latest https://eventos.seudominio.dev \
  --only-categories=performance,accessibility,best-practices,seo \
  --output=html --output=json --output-path=./relatorios/antes \
  --chrome-flags="--headless" --quiet

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" }
  }
}
Terminal
npm install --save-dev @lhci/cli
npm run build
npx lhci autorun

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: qualidade

on:
  push:
    branches: [main]
  pull_request:

jobs:
  verificar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 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

6. Sete melhorias que mudam o número

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>
  <source srcset="/img/hero-1200.avif" type="image/avif" />
  <source srcset="/img/hero-1200.webp" type="image/webp" />
  <img
    src="/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>:

HTML
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" />

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'
import Inicio from '../views/Inicio.vue'

const router = 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') },
  ],
})

export default router

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)

nginx
location ~* \.(?:js|css|woff2|png|jpg|jpeg|webp|avif|svg|ico)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
}

location = /index.html {
    add_header Cache-Control "no-cache";
}

7. Compressão (todas as métricas de rede). Texto comprime muito: um pacote de 300 KB costuma virar 90 KB com gzip.

/etc/nginx/nginx.conf (dentro do bloco http)

nginx
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_types text/plain text/css text/xml application/javascript application/json application/xml image/svg+xml;

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.

Terminal
sudo nginx -t && sudo systemctl reload nginx
curl -sI -H 'Accept-Encoding: gzip, br' https://eventos.seudominio.dev/assets/index.js | grep -i -E 'content-encoding|cache-control'

🔬 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?

7. Observabilidade: Sentry e pino

7.1 Erros de produção

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
npm install @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 * as Sentry from '@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) => {
  const status = 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' } }).

No front:

unieventos-web/src/main.js (trecho)

JavaScript
import * as Sentry from '@sentry/vue'

const app = createApp(App)

Sentry.init({
  app,
  dsn: import.meta.env.VITE_SENTRY_DSN,
  environment: import.meta.env.MODE,
  integrations: [Sentry.browserTracingIntegration({ router })],
  tracesSampleRate: 0.2,
  enabled: Boolean(import.meta.env.VITE_SENTRY_DSN),
})

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.

7.2 Logs estruturados com pino

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.

Terminal
npm install pino pino-http && npm install --save-dev pino-pretty

unieventos-api/src/log.js

JavaScript
import pino from 'pino'

export const log = 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
node src/server.js | npx pino-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}'

7.3 Rotação: o log que enche o disco

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:

Terminal
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress true

/etc/logrotate.d/unieventos

Texto
/home/deploy/.pm2/logs/*.log {
    daily
    rotate 14
    maxsize 20M
    compress
    delaycompress
    missingok
    notifempty
    copytruncate
}

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).

8. Uptime, robots.txt e sitemap.xml

Um monitor de disponibilidade chama uma URL em intervalos fixos e avisa quando a resposta muda. Configuração mínima: alvo https://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'

const rotas = Router()

rotas.get('/health', async (req, res) => {
  try {
    // Uma consulta trivial prova que a conexão com o banco está viva.
    await pool.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' })
  }
})

export default rotas

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.

unieventos-web/public/robots.txt

Texto
User-agent: *
Allow: /
Disallow: /admin

Sitemap: https://eventos.seudominio.dev/sitemap.xml

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:

unieventos-web/public/sitemap.xml

XML
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://eventos.seudominio.dev/</loc>
    <changefreq>weekly</changefreq>
    <priority>1.0</priority>
  </url>
</urlset>

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)

Terminal
cd ~/weblab/unieventos-web
mkdir -p relatorios
npx lighthouse@latest https://eventos.seudominio.dev \
  --output=html --output=json --output-path=./relatorios/antes \
  --chrome-flags="--headless" --quiet

Anote em relatorios/comparativo.md: nota de Performance, LCP, CLS, TBT e total transferido. Sem esta medição, o resto do capítulo vira opinião.

Passo 2 — ESLint e Prettier nos dois projetos

Instale as dependências das §2.2 e §2.3, crie os dois eslint.config.js, o .prettierrc (Capítulo 01) e o .prettierignore. Em cada projeto:

Terminal
npx eslint .
npx prettier --write .
npx eslint . --fix
npx eslint .

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.

Passo 3 — Testes dos dois projetos

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.

Terminal
cd ~/weblab/unieventos-api && npm test && npm run test:cobertura
cd ~/weblab/unieventos-web && npm run test:unit

Esperado: todos passam e a tabela de cobertura aparece. Se o processo da API travar sem terminar, o after não está fechando o servidor.

Passo 4 — As correções de performance

Aplique nesta ordem, rodando npm run build antes e depois do item 4:

  1. Converta a imagem do topo para WebP, redimensione-a e adicione width, height e fetchpriority="high".
  2. Ponha loading="lazy" nas imagens dos cards de evento.
  3. Ajuste a fonte com preconnect e display=swap (ou baixe os .woff2).
  4. Troque as rotas do Vue Router por importação dinâmica.
  5. No servidor, aplique os blocos de cache e de gzip da §6 e recarregue o nginx.
Terminal
sudo nginx -t && sudo systemctl reload nginx
curl -sI -H 'Accept-Encoding: gzip' https://eventos.seudominio.dev/ | grep -i -E 'content-encoding|cache-control'

Passo 5 — Publique e meça de novo

Terminal
cd ~/weblab/unieventos-web
npm run build
rsync -avz --delete dist/ meuvps:/var/www/unieventos-web/
npx lighthouse@latest https://eventos.seudominio.dev \
  --output=html --output=json --output-path=./relatorios/depois \
  --chrome-flags="--headless" --quiet

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.

Passo 6 — Sentry nos dois lados

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.

Passo 7 — pino, rotação, CI e uptime

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.

Como conferir

Comando ou ação Resultado esperado
npm run qualidade (API e front) Termina sem erro nos dois projetos
npm run test:cobertura Todos os testes passam; a tabela de cobertura aparece
npx lhci autorun Três execuções, asserções aprovadas, link do relatório
curl -sI no site publicado content-encoding: gzip e cache-control nos arquivos com hash
relatorios/comparativo.md Tabela antes/depois com LCP, CLS, TBT e nota
Rota de erro proposital Evento no painel do Sentry, com pilha legível
tail -1 no log da API Uma linha JSON com servico, level e msg
Parar a API por 6 minutos E-mail do monitor de uptime avisando que caiu

🧪 Laboratório

Nível A — Fixação

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?

Nível B — Aplicação

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.

Nível C — Desafio

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.

🏆 Desafios

⭐ O relatório antes e depois

performancedeployinvestigacao

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
  1. 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.
  2. 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.
  3. Para converter imagens sem instalar nada, use https://squoosh.app; compare WebP com qualidade 75 e AVIF com qualidade 50.
⭐⭐

⭐⭐ A rede de segurança do projeto autoral

testesci-cdnode

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
  1. 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.
  2. 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.
  3. 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.
  4. Em Settings → Branches → Add rule, marque Require status checks to pass before merging e escolha o job do workflow.
⭐⭐⭐

⭐⭐⭐ Painel de saúde do seu sistema

performancedeployseguranca

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
  1. import { onLCP, onINP, onCLS } from 'web-vitals' e, no callback, navigator.sendBeacon('/api/metricas', JSON.stringify(metrica)) — o sendBeacon sobrevive ao fechamento da aba.
  2. 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.
  3. 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.
  4. Este é o tipo de material que fecha bem o Marco final da sua trilha.

🐛 Erros comuns

Sintoma Causa Solução
ESLint couldn't find an eslint.config.js file ESLint 9 procura flat config na pasta em que foi executado; o projeto ainda tem .eslintrc.json Crie o eslint.config.js na raiz e apague o .eslintrc*; rode o ESLint de dentro da pasta certa
'process' is not defined no-undef no back-end globals.node não foi declarado em languageOptions.globals Acrescente globals: { ...globals.node } ao objeto que casa com aqueles arquivos
O editor formata ao salvar e o ESLint sublinha a mesma linha em vermelho Regras de estilo do ESLint brigando com o Prettier Instale eslint-config-prettier e deixe-o como último item do array
No test files found no vitest run Os arquivos não seguem o padrão de nome esperado (*.test.js, *.spec.js) Renomeie para tests/algo.test.js ou passe o caminho: npx vitest run tests/
Os testes passam mas o processo nunca sai Um pool de banco, um servidor ou um setInterval continua aberto Feche tudo em um afterAll do Vitest: pool.end(), servidor.close(), clearInterval
ReferenceError: ResizeObserver is not defined no Vitest O jsdom não implementa ResizeObserver, que o Vuetify usa Declare o substituto em vitest.setup.js e registre o arquivo em test.setupFiles
[Vue warn]: Failed to resolve component: v-btn no teste Componente montado sem o plugin do Vuetify mount(C, { global: { plugins: [createVuetify({ components, directives })] } })
A nota do Lighthouse muda 15 pontos entre duas execuções seguidas Medição única, com CPU e rede compartilhadas Rode três vezes e use a mediana; no CI, numberOfRuns: 3
CLS alto só no celular Imagens sem width/height, ou fonte que troca e muda a altura das linhas Declare as dimensões em toda imagem; use font-display: swap com fallback de métrica parecida
content-encoding ausente na resposta do nginx O tipo não está em gzip_types, ou o arquivo tem menos de gzip_min_length bytes Acrescente o tipo à lista; lembre que text/html é sempre comprimido e não deve ser listado
O usuário continua vendo a versão antiga do site depois do deploy index.html servido com cache longo Cache-Control: no-cache no index.html; só arquivos com hash recebem immutable
Nenhum evento aparece no Sentry, sem mensagem de erro DSN vazio, enabled: false, ou instrument.js carregado depois do Express Confira a variável no servidor; use node --import ./instrument.js; teste com uma rota que lança erro
A pilha no Sentry mostra t.e is not a function na linha 1 Código minificado sem source map enviado Ative build.sourcemap e envie os mapas com o plugin oficial do Sentry para Vite
O servidor parou e o disco está 100 % cheio de logs Nenhuma rotação configurada pm2 install pm2-logrotate ou um arquivo em /etc/logrotate.d/ com copytruncate

🏠 Para praticar depois da aula (1 h)

No repositório do seu projeto autoral (front e API):

  1. 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.
  2. 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).
  3. 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.
  4. 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/.

✅ Está no ar quando…

  • [ ] npm run lint e npm run formatar:conferir passam nos dois projetos, sem avisos (--max-warnings=0).
  • [ ] npm test na API roda pelo menos um teste de unidade e um de integração, e o processo encerra sozinho.
  • [ ] O workflow de qualidade roda em cada pull request e a branch main exige que ele passe.
  • [ ] Existem dois relatórios do Lighthouse no repositório e um comparativo.md explicando a diferença.
  • [ ] Performance e Accessibility do site publicado estão em 90 ou mais no modo Mobile.
  • [ ] curl -sI mostra content-encoding: gzip nos arquivos de texto e cache-control: public, immutable nos arquivos com hash.
  • [ ] Um erro proposital na API aparece no painel do Sentry em menos de um minuto, com pilha legível.
  • [ ] Uma linha de log da API é JSON válido, tem servico e level, e não contém token nem senha.
  • [ ] Um monitor de uptime vigia /health e você recebeu ao menos um alerta de teste.
  • [ ] https://eventos.seudominio.dev/robots.txt e /sitemap.xml respondem 200 com o conteúdo correto para produção.

📚 Para aprofundar

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.

DeployUnidade 3 · Infraestrutura, automação e qualidade

Capítulo 11 — IA como ferramenta de desenvolvimento

Deploy & Ferramentas · WebLab

🎯 Objetivos de aprendizagem

Ao final deste capítulo você será capaz de:

  • 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.

📋 Pré-requisitos

  • [ ] 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.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min O que o assistente é; os três formatos; anatomia de um prompt com contexto; os cinco usos que valem a pena
2 50 min Modos de falha: alucinação de API e de versão, pacote inexistente, código inseguro; segredos no prompt; protocolo de verificação
3 50 min Dependência e aprendizado; política pessoal de uso de IA; Passo a passo de revisão assistida do projeto autoral; Laboratório

1. O que a ferramenta faz, de verdade

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:

  1. 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.
  2. 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.
  3. 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.

1.1 Onde ele é excelente e onde é fraco

Tarefa Desempenho típico Por quê
Explicar um erro conhecido Muito bom A mensagem literal é um ótimo indexador do material de treino
Escrever código repetitivo (CRUD, formulário, teste) Muito bom 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.

2. Os três formatos de assistente

Os produtos mudam de nome o tempo todo; os três formatos, não. Saber em qual você está muda o que você precisa colar no prompt.

2.1 Chat (ChatGPT, Claude, Gemini)

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.

2.2 Autocompletar no editor (GitHub Copilot)

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.

3. Contexto é tudo: a anatomia de um prompt

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.

  1. Objetivo em uma frase — o que você quer que aconteça, não como fazer.
  2. Código real — o trecho mínimo que reproduz o problema, com o caminho do arquivo.
  3. Versões — Node, framework, biblioteca. É o elemento mais esquecido e o que mais evita alucinação.
  4. Erro literal — copiado e colado do terminal ou do console, inteiro, com a pilha.
  5. O que você já tentou — evita que ele repita o caminho que você já eliminou.
  6. 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.

3.1 Três frases que melhoram qualquer prompt

  • "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.

4. Cinco usos que valem o tempo

4.1 Explicar um erro

É 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.

4.2 Revisar código

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ão
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, 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 revisada
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 ?? {};

  if (!Number.isInteger(eventoId) || eventoId <= 0) {
    return res.status(400).json({ erro: 'Informe eventoId como número inteiro positivo.' });
  }
  if (typeof nome !== 'string' || nome.trim().length < 3) {
    return res.status(400).json({ erro: 'Informe nome com ao menos 3 caracteres.' });
  }
  if (typeof email !== 'string' || !email.includes('@')) {
    return res.status(400).json({ erro: 'Informe um e-mail válido.' });
  }

  const [eventos] = await pool.query('SELECT id FROM eventos WHERE id = ?', [eventoId]);
  if (eventos.length === 0) {
    return res.status(404).json({ erro: 'Evento não encontrado.' });
  }

  const [resultado] = await pool.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.

4.3 Gerar testes

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:

  1. Rodar. Teste gerado que nunca rodou é decoração.
  2. 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.
  3. 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.js
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { app } from '../src/app.js';
import { pool } from '../src/db/pool.js';

let servidor;
let base;

before(async () => {
  servidor = app.listen(0);
  await new Promise((pronto) => servidor.once('listening', pronto));
  base = `http://127.0.0.1:${servidor.address().port}`;
  await pool.query('DELETE FROM inscricoes');
  await pool.query('INSERT INTO eventos (id, titulo, vagas) VALUES (1, ?, 40) ' +
    'ON DUPLICATE KEY UPDATE titulo = VALUES(titulo)', ['Semana Acadêmica']);
});

after(async () => {
  servidor.close();
  await pool.end();
});

async function inscrever(corpo) {
  return fetch(`${base}/api/inscricoes`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(corpo)
  });
}

test('cria a inscrição e responde 201', async () => {
  const resposta = await inscrever({ eventoId: 1, nome: 'Ana Souza', email: 'ana@exemplo.com' });
  assert.equal(resposta.status, 201);
  const corpo = await resposta.json();
  assert.ok(Number.isInteger(corpo.id));
  assert.equal(corpo.nome, 'Ana Souza');
});

test('recusa inscrição sem nome', async () => {
  const resposta = await inscrever({ eventoId: 1, email: 'ana@exemplo.com' });
  assert.equal(resposta.status, 400);
  const corpo = await resposta.json();
  assert.match(corpo.erro, /nome/i);
});

test('recusa nome com menos de 3 caracteres', async () => {
  const resposta = await inscrever({ eventoId: 1, nome: 'An', email: 'ana@exemplo.com' });
  assert.equal(resposta.status, 400);
});

test('recusa evento inexistente', async () => {
  const resposta = await inscrever({ eventoId: 9999, nome: 'Ana Souza', email: 'ana@exemplo.com' });
  assert.equal(resposta.status, 404);
});

4.4 Escrever commit e README

Mensagem de commit é o uso mais subestimado. Dê a ele o diff em texto e peça o padrão que você usa:

Terminal
git add -p
git diff --staged > /tmp/mudanca.diff
wc -l /tmp/mudanca.diff
Texto
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.

Terminal
cat package.json
ls -R src | head -40
cat .env.example
grep -rn "router\.\(get\|post\|put\|delete\)" src/rotas | head -20
Texto
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.

4.5 Aprender uma API nova

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.

5. Como a IA erra

5.1 Alucinação de API e de versão

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 ⭐⭐.

5.2 Pacote que não existe

Peça uma funcionalidade específica e é comum vir um npm install nome-plausivel-qualquer. Antes de instalar qualquer pacote sugerido:

Terminal
npm view express-validator version
npm view pacote-que-a-ia-sugeriu version

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.

5.3 Código plausível e inseguro

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.

6. O que nunca entra em um prompt

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 prompt
import mysql from 'mysql2/promise';

export const pool = mysql.createPool({
  host: process.env.DB_HOST,          // valor real: um hostname da nuvem
  user: process.env.DB_USER,          // valor real: um usuário da aplicação
  password: process.env.DB_PASSWORD,  // valor real: senha forte, não colada aqui
  database: 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:

text remote: - GITHUB PUSH PROTECTION remote: Push cannot contain secrets remote: —— Supabase Service Key ———————————————————— remote: locations: remote: commit: 8a1f3c2

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.

  1. 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.
  2. 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.
  3. 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.
  4. Rode. npm run lint, npm test, curl na rota, clique na tela. Depois quebre de propósito e confirme que o teste falha.
  5. 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.

8. Dependência: o custo que só aparece depois

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.

9. Como usar IA sem enganar a si mesmo

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:

  1. 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.
  2. 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.

Passo 1 — Preparar o terreno

Terminal
cd seu-projeto-autoral
git status
git switch -c revisao-assistida
npm run lint
npm test

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.

Passo 2 — Pedir a revisão, com contexto completo

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 |

Passo 4 — Verificar cada achado, um por um

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 -X POST http://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 -X POST http://localhost:3000/api/inscricoes \
  -H "Content-Type: application/json" -d '{"eventoId":1}'

# achado 4: conferir se o pacote é mesmo necessário nesta versão
npm view express-async-errors version
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.

Passo 5 — Corrigir os três problemas confirmados

Um commit por problema, cada um com a mensagem escrita a partir do diff (§4.4):

Terminal
git add src/rotas/inscricoes.js
git commit -m "fix(inscricoes): usa placeholders do mysql2 na consulta"
npm test

git add src/rotas/inscricoes.js
git commit -m "fix(inscricoes): valida corpo e responde 400 com mensagem clara"
npm test

git add src/components/FormularioInscricao.vue
git commit -m "fix(inscricao): mostra mensagem de erro quando a API falha"
npm run lint

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.

Passo 6 — Gerar os testes e endurecê-los

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
npm test

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
npm test
# 2. troque res.status(201) por res.status(200) e rode
npm test
# 3. remova a checagem de evento inexistente e rode
npm test

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.

Passo 7 — README.md a partir de fatos

Colete os fatos e use o prompt da §4.4:

Terminal
cat package.json
cat .env.example
grep -rn "rotasApp.use\|router\." src/rotas | head -20
ls src/components

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.

Terminal
cd /tmp
git clone https://github.com/seu-usuario/seu-projeto.git teste-readme
cd teste-readme

Se travar em algum passo, o README está errado — não a sua memória. Corrija o README.

Passo 8 — Registrar o uso em IA.md

Markdown
# 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
git add ACHADOS.md IA.md README.md
git commit -m "docs: registra revisão assistida, achados e uso de IA"
git push -u origin revisao-assistida
gh pr create --title "Revisão assistida do projeto autoral" \
  --body "Três problemas confirmados e corrigidos; testes endurecidos; README e IA.md."

Como conferir

  1. gh pr view --web mostra o pull request com pelo menos quatro commits, um por correção mais o de documentação.
  2. ACHADOS.md tem toda linha com situação confirmado, falso ou estilo — nenhuma como a verificar — e os falsos têm o motivo escrito.
  3. npm test passa; e, com qualquer uma das três mutações do Passo 6, falha.
  4. Os curl que reproduziam os problemas agora respondem 201, 400 e 404, conforme o caso.
  5. 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.

🧪 Laboratório

Nível A — Fixação

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.

Nível B — Aplicação

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.

Nível C — Desafio

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 problemas
import express from 'express';
import cors from 'cors';
import { createClient } from '@supabase/supabase-js';

const app = express();
app.use(cors({ origin: '*', credentials: true }));
app.use(express.json());

const supabase = 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 } = await supabase
    .from('usuarios')
    .select('id, nome, papel')
    .eq('email', email)
    .eq('senha', senha);
  if (error) return res.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.

🏆 Desafios

⭐ Caderno de bordo da IA

iaprojetoinvestigacao

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
  1. Registre na hora, em três linhas. Reconstituir de memória no fim da semana produz um relato bonito e falso.
  2. Separe por tipo de tarefa: explicar erro, gerar código novo, revisar, documentar. As taxas são bem diferentes entre elas.
  3. "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.
  4. Este é o tipo de registro que fecha bem o Marco final da sua trilha.
⭐⭐

⭐⭐ Placar do caça-alucinação

iainvestigacaotestes

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
  1. 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.
  2. 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.
  3. 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.
  4. Para a comparação final, mude a menção de versão no prompt. Qualquer outra mudança invalida a comparação.
⭐⭐⭐

⭐⭐⭐ Revisor de segurança do seu projeto

iasegurancatestesprojeto

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
  1. Comece pelas rotas de escrita e pelas que retornam dados de outra pessoa — é onde mora quase toda falha de autorização.
  2. 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.
  3. 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.
  4. 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.
🔥

🔥 Boss — Tudo no ar, medido e defensável

deployci-cdiasegurancaprojeto

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
  1. 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.
  2. Teste a restauração do backup em uma base nova e vazia. Backup nunca restaurado não é backup — é um arquivo.
  3. 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.
  4. 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.

🐛 Erros comuns

Sintoma Causa Solução
TypeError: Cannot destructure property 'nome' of 'req.body' as it is undefined Código gerado sem express.json(); no Express 5 req.body é undefined, não {} app.use(express.json()) antes das rotas; ou const { nome } = req.body ?? {}
TypeError: app.del is not a function Sugestão em sintaxe do Express 4 Use app.delete(); sempre informe "Express 5" no prompt
Error: Cannot find module 'body-parser' A IA mandou instalar um pacote que virou nativo express.json() e express.urlencoded() já vêm no Express; remova a dependência
TypeError: firebase.auth is not a function Resposta com a API de namespace, anterior à modular import { getAuth, signInWithEmailAndPassword } from 'firebase/auth'
npm error code E404is not in this registry Nome de pacote alucinado Confira com npm view <pacote> version antes de instalar; procure alternativa real
Error: ER_PARSE_ERROR: You have an error in your SQL syntax ao gravar um nome com apóstrofo SQL montado por concatenação (o mesmo defeito que permite injeção) Placeholders ? do mysql2 e valores no array de parâmetros
A consulta do Supabase devolve data: [] e error: null, mas há linhas na tabela RLS habilitado sem policy; o código gerado esqueceu a policy Crie a policy de select no painel; nunca use a chave de serviço no front
Access to fetch … has been blocked by CORS policy depois de publicar cors() local aberto, produção com origem diferente Configure origin com a URL do front publicado; nada de origin: '*' com credenciais
SyntaxError: Cannot use import statement outside a module Resposta em ESM colada em projeto CommonJS (ou o contrário) Declare "type": "module" no package.json ou peça a resposta em CommonJS
A documentação do Swagger sobe vazia, sem erro Resposta usando swaggerDefinition, chave da versão antiga swagger-jsdoc 6.x usa definition
remote: - GITHUB PUSH PROTECTIONPush cannot contain secrets Chave colada no código pela IA ou por você, e commitada Rotacione a chave no painel do serviço, tire do código, use .env e secrets
Todos os testes gerados passam, mesmo com o código quebrado de propósito Asserções frouxas (assert.ok) e nenhum caso de erro Compare valores exatos e corpo da resposta; teste de mutação como no Passo 6
npm error code ERESOLVE ao instalar o que a IA sugeriu Versões incompatíveis inventadas no package.json Instale sem fixar versão (npm install pacote) e deixe o npm resolver; não force --legacy-peer-deps sem entender
O componente Vue some da tela depois de uma refatoração do agente Mudança ampla aceita sem ler o diff git diff antes de aceitar; git reset --hard para o commit-âncora e refaça em pedaços

🏠 Para praticar depois da aula (1 h)

No repositório do seu projeto autoral:

  1. 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.
  2. 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ê.
  3. 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.
  4. Escreva o README.md a partir de fatos do repositório e valide clonando em outra pasta e seguindo apenas o que está escrito.
  5. 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.

✅ Está no ar quando…

  • [ ] O repositório do projeto autoral tem IA.md na raiz, declarando onde você usou assistente, o que aceitou e o que recusou.
  • [ ] ACHADOS.md registra a revisão assistida com cada item classificado e nenhum pendente.
  • [ ] Pelo menos três problemas reais foram corrigidos, cada um com o seu commit e com o curl (ou passo na tela) que reproduzia o problema antes.
  • [ ] Pelo menos uma sugestão da IA foi recusada com justificativa técnica e link da documentação oficial.
  • [ ] npm test passa, e falha quando você quebra a regra de negócio de propósito em qualquer um de três pontos.
  • [ ] npm run lint passa sem avisos nos dois projetos.
  • [ ] O README.md foi validado em um clone novo: quem nunca viu o projeto consegue rodá-lo seguindo só o texto.
  • [ ] Nenhum segredo entrou no repositório nem em prompt: git log -p -S "eyJ" não retorna chaves e o .env continua ignorado.
  • [ ] Você consegue explicar, sem abrir o editor, o que cada uma das três correções faz e o que aconteceria se fosse removida.

📚 Para aprofundar


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.

🎯 Objetivos de aprendizagem📋 Pré-requisitos🗺️ Roteiro1. O que a ferramenta faz, de verdade1.1 Onde ele é excelente e onde é fraco2. Os três formatos de assistente2.1 Chat (ChatGPT, Claude, Gemini)2.2 Autocompletar no editor (GitHub Copilot)2.3 Agente no editor ou no terminal (Claude Code, Cursor)3. Contexto é tudo: a anatomia de um prompt3.1 Três frases que melhoram qualquer prompt4. Cinco usos que valem o tempo4.1 Explicar um erro4.2 Revisar código4.3 Gerar testes4.4 Escrever commit e README4.5 Aprender uma API nova5. Como a IA erra5.1 Alucinação de API e de versão5.2 Pacote que não existe5.3 Código plausível e inseguro6. O que nunca entra em um prompt7. Verificar é o trabalho: o protocolo dos cinco passos8. Dependência: o custo que só aparece depois9. Como usar IA sem enganar a si mesmo🚀 Passo a passo — Revisão assistida do projeto autoral, com tudo verificadoPasso 1 — Preparar o terrenoPasso 2 — Pedir a revisão, com contexto completoPasso 3 — Triagem: transformar a resposta em lista verificávelPasso 4 — Verificar cada achado, um por umPasso 5 — Corrigir os três problemas confirmadosPasso 6 — Gerar os testes e endurecê-losPasso 7 — README.md a partir de fatosPasso 8 — Registrar o uso em IA.mdComo conferir🧪 LaboratórioNível A — FixaçãoNível B — AplicaçãoNível C — Desafio🏆 Desafios⭐ Caderno de bordo da IA⭐⭐ Placar do caça-alucinação⭐⭐⭐ Revisor de segurança do seu projeto🔥 Boss — Tudo no ar, medido e defensável🐛 Erros comuns🏠 Para praticar depois da aula (1 h)✅ Está no ar quando…📚 Para aprofundar
WebLab — Laboratório de Desenvolvimento Web
Conteúdo sob CC BY 4.0; o gerador do site, sob licença MIT. Reuso livre com atribuição.
Início · Autoria e créditos · Banco de Desafios · Links úteis · Fontes no GitHub