Você sabe construir aplicações. Mas construir é apenas metade do trabalho — a outra metade é colocar no ar para que outras pessoas possam usar. Deploy é o processo de levar o código do seu computador para um servidor acessível pela internet.
Muitos desenvolvedores excelentes na escrita de código travam quando precisam fazer deploy. Isso acontece porque deploy envolve um conjunto diferente de conceitos — servidores, variáveis de ambiente, builds, domínios, certificados SSL. Este artigo desmistifica tudo isso.
Vamos cobrir deploy do front-end React e do back-end Node.js, usando as plataformas mais usadas pelo mercado em 2025.
O que acontece em um deploy
Antes de qualquer comando, é importante entender conceitualmente o que estamos fazendo. O processo é sempre o mesmo, independente da plataforma.
Para o front-end React, o código precisa ser compilado — o Vite transforma JSX, importações e otimizações em arquivos HTML, CSS e JavaScript puros que qualquer navegador entende. Esses arquivos estáticos são então colocados em um servidor que os entrega ao navegador quando alguém acessa a URL.
Para o back-end Node.js, o processo é diferente — o código não é compilado para o usuário final, mas precisa rodar em um servidor com Node instalado, conectado ao banco de dados, com as variáveis de ambiente configuradas e disponível 24 horas por dia.
Desenvolvimento Produção
──────────────────────── ────────────────────────────────
npm run dev npm run build → arquivos estáticos
localhost:5173 CDN global (Vercel, Netlify)
Hot reload automático Arquivos servidos ao edge
node src/index.js Processo gerenciado (PM2, Railway)
localhost:3000 Servidor com IP público
.env local Variáveis de ambiente na plataforma
MongoDB local MongoDB Atlas (nuvem)
Deploy do front-end — Vercel
A Vercel é a plataforma mais popular para deploy de aplicações React e foi criada pelos autores do Next.js. O deploy é tão simples que parece mágica — você conecta o repositório do GitHub e a Vercel cuida de todo o resto.
Preparando o projeto React para produção
Antes do deploy, precisamos garantir que o projeto está configurado corretamente. O passo mais importante é usar variáveis de ambiente para a URL da API — nunca escreva URLs hardcoded no código.
# Variáveis de ambiente no Vite usam o prefixo VITE_
# Apenas variáveis com este prefixo chegam ao código do navegador.
#
# ⚠️ CUIDADO COM A LEITURA INVERSA: o prefixo não protege nada. Ele marca
# o que SERÁ EXPOSTO. Toda variável VITE_ é substituída pelo seu valor
# literal dentro do bundle, em texto puro, e qualquer visitante a lê abrindo
# o arquivo .js no DevTools. Não existe "segredo do front-end".
#
# A regra é: VITE_ só para o que pode ser público — URL da API, chave de
# mapa restrita por domínio, id de projeto. Chave de API privada, segredo de
# JWT e credencial de banco vivem NO SERVIDOR, sem prefixo nenhum, e o
# navegador só conversa com eles através da sua própria API.
# .env.development — usado em npm run dev
VITE_API_URL=http://localhost:3000
# .env.production — usado em npm run build
VITE_API_URL=https://sua-api.railway.app
// src/services/api.js
// import.meta.env é a forma do Vite acessar variáveis de ambiente
// Em desenvolvimento: http://localhost:3000
// Em produção: https://sua-api.railway.app
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export async function api(endpoint, opcoes = {}) {
// ... resto da implementação
}
// vite.config.js
// Configurações importantes para produção
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
// Divide o bundle em chunks menores para carregamento mais rápido
// Cada rota lazy() vira um arquivo separado
rollupOptions: {
output: {
// Agrupa bibliotecas grandes em um chunk separado
// Isso melhora o cache — react e react-dom raramente mudam
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
query: ['@tanstack/react-query'],
},
},
},
// Avisa quando algum chunk ultrapassar 500KB
chunkSizeWarningLimit: 500,
},
});
Para SPAs com React Router, precisamos de um arquivo especial que instrui a Vercel a sempre servir o index.html, independente da rota acessada. Sem isso, acessar diretamente /produtos/42 retornaria um 404.
// vercel.json — na raiz do projeto
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}
Deploy na Vercel passo a passo
# Opção 1 — via CLI (mais rápido para testar)
npm install -g vercel
vercel login
vercel # deploy para preview
vercel --prod # deploy para produção
# Opção 2 — via GitHub (recomendado para projetos contínuos)
# 1. Suba o código para o GitHub
# 2. Acesse vercel.com e clique em "New Project"
# 3. Importe o repositório
# 4. Configure as variáveis de ambiente na interface:
# VITE_API_URL = https://sua-api.railway.app
# 5. Clique em Deploy
#
# A partir daí, cada push para main faz deploy automático
# Pull Requests ganham URLs de preview automáticas
Deploy do front-end — Netlify
A Netlify é outra excelente opção, especialmente popular para projetos open source. O processo é similar à Vercel.
# netlify.toml — na raiz do projeto
[build]
# Comando que gera os arquivos de produção
command = "npm run build"
# Pasta com os arquivos gerados pelo build
publish = "dist"
# Redirect para SPA — sem isso, rotas diretas retornam 404
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# Via CLI
npm install -g netlify-cli
netlify login
netlify deploy --build # preview
netlify deploy --build --prod # produção
Build local — sempre teste antes do deploy
Um erro comum é fazer push e descobrir que o build de produção falha por algo que funcionava em desenvolvimento. Desenvolva o hábito de testar o build localmente antes de enviar.
# Gera os arquivos de produção na pasta dist/
npm run build
# Serve os arquivos de produção localmente
# Isso simula exatamente como vai funcionar no servidor
npm run preview
# Abra http://localhost:4173 e teste tudo:
# - navegação entre rotas
# - login e autenticação
# - chamadas à API
# - lazy loading das páginas
Deploy do back-end — Railway
O Railway é a plataforma mais simples para fazer deploy de aplicações Node.js com banco de dados. Ele detecta automaticamente que é um projeto Node e configura tudo.
Preparando a API para produção
O primeiro passo é garantir que a aplicação lê corretamente as variáveis de ambiente e que não há nada hardcoded.
// src/config/index.js
// Centraliza toda a configuração em um lugar
// Se alguma variável obrigatória estiver faltando, a aplicação
// para imediatamente com uma mensagem clara — melhor do que falhar silenciosamente
require('dotenv').config();
const config = {
porta: Number(process.env.PORT) || 3000,
ambiente: process.env.NODE_ENV || 'development',
mongoUrl: process.env.MONGODB_URL,
jwtSecret: process.env.JWT_SECRET,
jwtExpiracao: process.env.JWT_EXPIRA_EM || '7d',
// Helpers para verificar o ambiente atual
eDev: process.env.NODE_ENV === 'development',
eProd: process.env.NODE_ENV === 'production',
eTeste: process.env.NODE_ENV === 'test',
};
// Valida variáveis obrigatórias no startup
// Em produção, se MONGODB_URL não existir, é melhor parar agora
// do que falhar misteriosamente na primeira requisição ao banco
const obrigatorias = ['MONGODB_URL', 'JWT_SECRET'];
const faltando = obrigatorias.filter((v) => !process.env[v]);
if (faltando.length > 0 && config.eProd) {
console.error(
`[Config] Variáveis de ambiente obrigatórias faltando: ${faltando.join(', ')}`
);
process.exit(1);
}
module.exports = config;
// src/index.js
// Ajustes importantes para produção
const express = require('express');
const cors = require('cors');
const config = require('./config');
const { conectar } = require('./config/database');
const app = express();
// CORS configurado para aceitar apenas origens conhecidas em produção
// Em desenvolvimento, aceita qualquer origem para facilitar os testes
const origemPermitida = config.eProd
? process.env.FRONTEND_URL // Ex: https://meuapp.vercel.app
: '*';
app.use(
cors({
origin: origemPermitida,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
})
);
app.use(express.json({ limit: '10mb' }));
// Health check — endpoint simples que o Railway e outros serviços
// usam para saber se a aplicação está viva
// Se retornar 200, o serviço está ok. Se não responder, reinicia o processo.
app.get('/health', (req, res) => {
res.json({
status: 'ok',
ambiente: config.ambiente,
uptime: Math.floor(process.uptime()) + 's',
timestamp: new Date().toISOString(),
});
});
// Rotas da API
app.use('/auth', require('./routes/auth'));
app.use('/produtos', require('./routes/produtos'));
app.use('/tarefas', require('./routes/tarefas'));
// Middlewares de erro
app.use(require('./middlewares/erros').naoEncontrado);
app.use(require('./middlewares/erros').tratadorDeErros);
// Inicialização
conectar().then(() => {
app.listen(config.porta, () => {
console.log(`[Server] Rodando na porta ${config.porta} (${config.ambiente})`);
});
});
// Encerramento gracioso — finaliza requisições em andamento antes de parar
// O Railway envia SIGTERM antes de reiniciar o container
process.on('SIGTERM', () => {
console.log('[Server] SIGTERM recebido. Encerrando...');
process.exit(0);
});
module.exports = app;
// package.json — scripts importantes para produção
{
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest"
},
"engines": {
"node": ">=18.0.0"
}
}
O campo engines instrui o Railway (e outras plataformas) sobre qual versão do Node usar. Sempre especifique.
Deploy no Railway
# Via CLI
npm install -g @railway/cli
railway login
railway init # inicializa o projeto no Railway
railway up # faz o deploy
# Configurando variáveis de ambiente via CLI
railway variables set NODE_ENV=production
railway variables set JWT_SECRET=sua-chave-super-secreta-aqui
railway variables set MONGODB_URL=mongodb+srv://...
railway variables set FRONTEND_URL=https://seuapp.vercel.app
Também é possível configurar tudo pela interface web do Railway, que é mais visual e recomendada para iniciantes.
MongoDB Atlas — banco de dados em nuvem
Em produção, não podemos usar o MongoDB local. O MongoDB Atlas é o serviço de nuvem oficial do MongoDB com um tier gratuito generoso.
Criando um cluster no Atlas:
1. Acesse cloud.mongodb.com e crie uma conta
2. Crie um novo projeto e um cluster (M0 Free é suficiente para começar)
3. Em "Database Access": crie um usuário com senha forte
4. Em "Network Access": adicione 0.0.0.0/0 (aceita conexão de qualquer IP)
— Em produção real, você restringiria ao IP do seu servidor
5. Em "Connect": escolha "Connect your application"
6. Copie a connection string:
mongodb+srv://usuario:senha@cluster.mongodb.net/nomebanco
Esta string vai para a variável de ambiente MONGODB_URL no Railway.
// src/config/database.js
// Configuração de conexão otimizada para produção
const mongoose = require('mongoose');
async function conectar() {
const url = process.env.MONGODB_URL;
if (!url) {
throw new Error('MONGODB_URL não configurada.');
}
try {
await mongoose.connect(url, {
// Timeout para encontrar um servidor disponível no cluster
serverSelectionTimeoutMS: 10000,
// Timeout para operações individuais no banco
socketTimeoutMS: 45000,
});
console.log(`[DB] Conectado: ${mongoose.connection.host}`);
// Eventos de conexão para monitoramento em produção
mongoose.connection.on('disconnected', () => {
console.warn('[DB] Desconectado do MongoDB.');
});
mongoose.connection.on('error', (erro) => {
console.error('[DB] Erro de conexão:', erro.message);
});
// Reconecta automaticamente se a conexão cair
mongoose.connection.on('reconnected', () => {
console.info('[DB] Reconectado ao MongoDB.');
});
} catch (erro) {
console.error('[DB] Falha ao conectar:', erro.message);
throw erro;
}
}
module.exports = { conectar };
Variáveis de ambiente — organização e segurança
Um dos erros mais comuns e perigosos em deploy é vazar secrets. Estas são as regras invioláveis.
# ✅ O que SEMPRE vai no .gitignore
.env
.env.local
.env.production
.env.*.local
# ✅ O que vai no repositório (sem valores reais)
.env.example
# .env.example — template documentado para o time
# Cada desenvolvedor copia este arquivo para .env e preenche os valores
NODE_ENV=development
PORT=3000
MONGODB_URL=mongodb://localhost:27017/meuapp
JWT_SECRET= # gere com: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
JWT_EXPIRA_EM=7d
FRONTEND_URL=http://localhost:5173
# Gerando um JWT_SECRET seguro no terminal
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# Saída: a4f8c2e9b1d7... (128 caracteres hexadecimais)
# Use este valor como JWT_SECRET — nunca use "segredo123" em produção
Pipeline de CI/CD com GitHub Actions
CI/CD automatiza o processo de teste e deploy. Toda vez que você faz push para main, o GitHub Actions executa os testes e, se passarem, faz o deploy automaticamente. Isso elimina deploys manuais e garante que código com testes falhando nunca vai para produção.
# .github/workflows/deploy.yml
# Este arquivo define o pipeline de CI/CD
name: Testes e Deploy
# Dispara quando há push para a branch main
on:
push:
branches: [main]
# Também roda em Pull Requests — mas sem o step de deploy
pull_request:
branches: [main]
jobs:
# Job 1: roda os testes
testar:
runs-on: ubuntu-latest
name: Testes automatizados
steps:
# Faz checkout do código
- uses: actions/checkout@v4
# Configura Node.js na versão especificada
- name: Configurar Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # cacheia node_modules entre execuções
# Instala dependências com versões exatas do lockfile
- name: Instalar dependências
run: npm ci
# Verifica formatação e lint
- name: Verificar qualidade de código
run: npm run quality
# Roda os testes com relatório de cobertura
- name: Executar testes
run: npm run test:coverage
env:
# Variáveis de ambiente para os testes
# Secrets são configurados no GitHub: Settings → Secrets
MONGODB_TEST_URL: ${{ secrets.MONGODB_TEST_URL }}
JWT_SECRET: test-secret-apenas-para-testes
# Job 2: deploy (só roda se os testes passarem e for push para main)
deploy:
needs: testar # aguarda o job "testar" completar com sucesso
runs-on: ubuntu-latest
# Só faz deploy em push direto para main (não em Pull Requests)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
name: Deploy para produção
steps:
- uses: actions/checkout@v4
- name: Configurar Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Instalar dependências
run: npm ci
# Deploy no Railway usando o token de autenticação
# RAILWAY_TOKEN é configurado em GitHub Secrets
- name: Deploy no Railway
run: |
npm install -g @railway/cli
railway up --service ${{ secrets.RAILWAY_SERVICE_ID }}
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
Para o front-end no Vercel, o deploy é ainda mais simples — a própria Vercel detecta novos commits no GitHub e faz deploy automaticamente, sem precisar de GitHub Actions.
Monitorando a aplicação em produção
Depois do deploy, você precisa saber se tudo está funcionando. Há três tipos de monitoramento essenciais.
Logs mostram o que está acontecendo em tempo real. O Railway e o Vercel têm dashboards de log integrados.
Uptime monitoring avisa quando sua aplicação cai. O UptimeRobot (gratuito) faz pings no seu /health a cada 5 minutos e manda email se não responder.
Error tracking captura exceções com contexto — qual usuário estava, qual ação executou, qual linha do código falhou. O Sentry tem um plano gratuito excelente.
// Integrando Sentry no back-end Node.js
// npm install @sentry/node
const Sentry = require('@sentry/node');
// Inicializa ANTES de qualquer coisa
Sentry.init({
// DSN vem das configurações do projeto no sentry.io
dsn: process.env.SENTRY_DSN,
// Captura 10% das transações para não estourar a cota gratuita
tracesSampleRate: 0.1,
// Não captura erros em desenvolvimento
enabled: process.env.NODE_ENV === 'production',
});
// No error handler do Express — Sentry precisa vir ANTES do seu handler
app.use(Sentry.Handlers.errorHandler());
app.use(tratadorDeErros); // seu handler customizado
Checklist de deploy
Antes de cada deploy em produção, percorra este checklist mentalmente. Ele foi construído a partir de erros reais — cada item representa algo que já deu errado em algum projeto.
Front-end (React)
─────────────────────────────────────────────────────────
[ ] npm run build executa sem erros
[ ] npm run preview funciona corretamente
[ ] VITE_API_URL aponta para a API de produção
[ ] vercel.json ou netlify.toml com redirect configurado
[ ] Nenhuma URL de localhost hardcoded no código
[ ] Console.log de debug removidos (ou só em dev)
[ ] Lazy loading em todas as páginas grandes
Back-end (Node.js)
─────────────────────────────────────────────────────────
[ ] Todas as configurações leem de process.env
[ ] .env.example atualizado com novas variáveis
[ ] .env nunca commitado (está no .gitignore)
[ ] JWT_SECRET é uma string longa e aleatória
[ ] MONGODB_URL aponta para o Atlas (não para localhost)
[ ] CORS configurado com a URL do front-end de produção
[ ] NODE_ENV=production nas variáveis de ambiente
[ ] Campo "engines" no package.json com versão do Node
[ ] Endpoint /health respondendo 200
[ ] Testes passando (npm test)
Banco de dados (MongoDB Atlas)
─────────────────────────────────────────────────────────
[ ] Cluster criado e rodando
[ ] Usuário do banco criado com senha forte
[ ] Network Access configurado
[ ] Connection string testada localmente antes do deploy
Geral
─────────────────────────────────────────────────────────
[ ] Domínio customizado configurado (opcional)
[ ] HTTPS ativo (Vercel e Railway fazem isso automaticamente)
[ ] Uptime monitor configurado
[ ] Sentry ou similar para error tracking
Domínio customizado
Após o deploy, você recebe uma URL gerada automaticamente como meuapp.vercel.app. Para usar um domínio próprio como meuapp.com.br, o processo é simples.
1. Compre o domínio em um registrador (Registro.br, GoDaddy, Namecheap)
2. No painel da Vercel:
Settings → Domains → Add → "meuapp.com.br"
A Vercel mostrará os registros DNS que você precisa configurar
3. No painel do seu registrador de domínio:
Adicione os registros DNS fornecidos pela Vercel:
Tipo: A Nome: @ Valor: 76.76.21.21
Tipo: CNAME Nome: www Valor: cname.vercel-dns.com
4. Aguarde a propagação DNS (pode levar de minutos a 24 horas)
5. HTTPS é configurado automaticamente pela Vercel via Let's Encrypt
Tarefa para você
Faça o deploy completo da SPA construída no Módulo 6:
# Back-end
# 1. Crie um cluster gratuito no MongoDB Atlas
# e configure a connection string
# 2. Crie uma conta no Railway e faça deploy da API
# Configurar variáveis de ambiente:
# NODE_ENV, PORT, MONGODB_URL, JWT_SECRET, FRONTEND_URL
# 3. Teste o endpoint /health da API em produção:
# curl https://sua-api.railway.app/health
# Front-end
# 4. Configure o arquivo vercel.json para SPA routing
# 5. Crie uma conta na Vercel e importe o repositório
# Configurar variável:
# VITE_API_URL = https://sua-api.railway.app
# 6. Faça login e crie um produto pela URL de produção
# CI/CD
# 7. Configure o GitHub Actions com o workflow do artigo
# Adicione os secrets no GitHub:
# RAILWAY_TOKEN, RAILWAY_SERVICE_ID, MONGODB_TEST_URL
# 8. Faça um commit e observe o pipeline rodando:
# testes → qualidade de código → deploy automático
# Monitoramento
# 9. Crie uma conta gratuita no UptimeRobot
# Monitore o endpoint /health a cada 5 minutos
# 10. Compartilhe a URL da aplicação funcionando em produção!
Ver solução — o deploy completo — health honesto, encerramento gracioso, Vercel e o pipeline
// 1 e 2 — MongoDB Atlas e Railway (feito pelo painel de cada serviço)
//
// Atlas: cluster M0 (gratuito) → Database Access com usuário só desta aplicação
// → Network Access. Em produção, libere o IP do Railway; 0.0.0.0/0 é aceitável
// só enquanto o serviço não tem IP fixo, e é a primeira coisa a fechar depois.
//
// Railway: New Project → Deploy from GitHub repo → Variables:
//
// NODE_ENV=producao
// PORT=3000 (o Railway injeta a porta; leia process.env.PORT)
// MONGODB_URL=mongodb+srv://usuario:senha@cluster.mongodb.net/tarefas
// JWT_SECRET=<openssl rand -base64 48>
// FRONTEND_URL=https://seu-app.vercel.app
//
// O JWT_SECRET de produção NUNCA é o do .env local. Gere um novo:
//
// openssl rand -base64 48
// ---- src/health.js
// 3 — O ENDPOINT /health
const mongoose = require("mongoose");
const ESTADOS = ["desconectado", "conectado", "conectando", "desconectando"];
// A conexão entra por parâmetro em vez de ser lida de dentro. Duas razões:
// o teste passa uma conexão falsa para simular o banco fora do ar, sem
// mexer no mongoose de verdade (redefinir `readyState` o deixa só-leitura e
// quebra o disconnect no fim da suíte); e o dia em que houver uma segunda
// conexão, esta rota não precisa mudar.
function criarRotaHealth(conexao = mongoose.connection) {
// O /health que o Railway e o UptimeRobot vão bater. Duas regras: não pode
// exigir autenticação, e tem de dizer a VERDADE — um 200 fixo transforma o
// monitoramento em enfeite.
return function rotaHealth(req, res) {
const bancoOk = conexao.readyState === 1;
res.status(bancoOk ? 200 : 503).json({
status: bancoOk ? "ok" : "degradado",
banco: ESTADOS[conexao.readyState] ?? "desconhecido",
versao: process.env.npm_package_version || "0.0.0",
uptime: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
});
};
}
module.exports = { criarRotaHealth };
// ---- src/servidor.js
// Encerramento gracioso. O Railway manda SIGTERM e espera; se o processo
// morre na hora, as requisições em voo viram erro no navegador do usuário.
//
// `sair` entra por parâmetro em vez de `process.exit` direto: assim o teste
// injeta uma função falsa e não precisa espionar o process — espionar
// process.exit deixa o Jest terminar a suíte com o banco ainda aberto.
function encerrarComGraca(servidor, fechar, { sair = process.exit } = {}) {
let encerrando = false;
const encerrar = async (sinal) => {
if (encerrando) return; // dois SIGTERM não podem fechar duas vezes
encerrando = true;
console.log(`[${sinal}] encerrando...`);
// Rede de segurança: requisição pendurada não pode segurar o processo
// para sempre. 10s é mais que o timeout de qualquer rota sã.
const prazo = setTimeout(() => {
console.error("[encerrado] forçado após 10s");
sair(1);
}, 10_000);
prazo.unref();
// Para de aceitar conexões novas e espera as atuais terminarem.
servidor.close(async () => {
clearTimeout(prazo); // saiu com graça: o prazo não precisa mais existir
await fechar();
console.log("[encerrado] com graça");
sair(0);
});
};
process.on("SIGTERM", () => encerrar("SIGTERM"));
process.on("SIGINT", () => encerrar("SIGINT"));
return encerrar;
}
module.exports = { encerrarComGraca };
// ---- src/index.js (juntando as duas coisas)
// const app = require("./app");
// const { conectar } = require("./config/database");
// const { encerrarComGraca } = require("./servidor");
// const { criarRotaHealth } = require("./health");
// const mongoose = require("mongoose");
//
// app.get("/health", criarRotaHealth());
//
// // process.env.PORT SEM valor fixo: a plataforma escolhe a porta e injeta.
// // Fixar 3000 é o erro que faz o deploy subir e o healthcheck nunca passar.
// const PORTA = process.env.PORT || 3000;
//
// conectar().then(() => {
// const servidor = app.listen(PORTA, () => console.log(`no ar em :${PORTA}`));
// encerrarComGraca(servidor, () => mongoose.disconnect());
// });
// ---- vercel.json
// 4 — VERCEL: ROTEAMENTO DE SPA
//
// O `rewrites` é o item inteiro. Sem ele, /produtos/42 acessado direto (ou um
// F5 nessa URL) devolve 404: o servidor procura um arquivo que não existe. A
// regra manda tudo para o index.html e deixa o React Router resolver.
//
// O cache também importa: os arquivos em /assets têm hash no nome, então podem
// ser imutáveis por um ano; o index.html NÃO pode ser cacheado, senão o
// navegador continua pedindo o bundle antigo depois do deploy.
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "/index.html",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
}
]
}
// ---- .github/workflows/deploy.yml
// 7 e 8 — O PIPELINE
name: Testes e Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
# Cancela execuções antigas do mesmo branch: sem isto, três pushes seguidos
# fazem três deploys concorrentes e o último a terminar vence — que pode ser
# o commit mais antigo.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
testar:
runs-on: ubuntu-latest
name: Testes automatizados
steps:
- uses: actions/checkout@v4
- name: Configurar Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Instalar dependências
run: npm ci
- name: Verificar qualidade de código
run: npm run quality
- name: Executar testes
run: npm run test:coverage
env:
MONGODB_TEST_URL: ${{ secrets.MONGODB_TEST_URL }}
JWT_SECRET: test-secret-apenas-para-testes
- name: Auditar dependências
run: npm audit --audit-level=high
deploy-api:
needs: testar
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
name: Deploy da API no Railway
steps:
- uses: actions/checkout@v4
- name: Configurar Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Instalar dependências
run: npm ci
- name: Deploy no Railway
run: |
npm install -g @railway/cli
railway up --service ${{ secrets.RAILWAY_SERVICE_ID }}
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
# 3 — o passo que fecha o ciclo: deploy que sobe quebrado tem de
# falhar o pipeline, não ficar verde. O sleep dá tempo de o container
# trocar; o --fail faz o curl devolver código de erro em 5xx.
- name: Conferir o /health em produção
run: |
sleep 20
for tentativa in 1 2 3 4 5; do
if curl --fail --silent --show-error "${{ secrets.API_URL }}/health"; then
echo "API respondeu OK na tentativa $tentativa"
exit 0
fi
echo "tentativa $tentativa falhou; aguardando..."
sleep 10
done
echo "API não respondeu após 5 tentativas"
exit 1
deploy-front:
needs: deploy-api
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
name: Deploy da SPA na Vercel
steps:
- uses: actions/checkout@v4
- name: Configurar Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Instalar e construir
run: |
npm ci
npm run build
env:
VITE_API_URL: ${{ secrets.API_URL }}
- name: Deploy na Vercel
run: |
npm install -g vercel
vercel deploy --prod --yes --token ${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
// ---- testes/integracao/deploy.test.js
// OS TESTES DA PARTE QUE É CÓDIGO — 6, todos passando
const request = require("supertest");
const express = require("express");
const mongoose = require("mongoose");
const { criarRotaHealth } = require("../../src/health");
const { encerrarComGraca } = require("../../src/servidor");
function app(conexao = mongoose.connection) {
const a = express();
a.get("/health", criarRotaHealth(conexao));
return a;
}
describe("3 — o /health que o monitoramento vai bater", () => {
it("responde 200 com o banco conectado", async () => {
const resposta = await request(app()).get("/health");
expect(resposta.status).toBe(200);
expect(resposta.body).toMatchObject({ status: "ok", banco: "conectado" });
expect(typeof resposta.body.uptime).toBe("number");
});
it("responde 503 quando o banco cai — e não 200", async () => {
// Conexão falsa: nada é alterado no mongoose real.
const resposta = await request(app({ readyState: 0 })).get("/health");
expect(resposta.status).toBe(503);
expect(resposta.body.status).toBe("degradado");
expect(resposta.body.banco).toBe("desconectado");
});
it("responde 503 durante a reconexão, não 200", async () => {
const resposta = await request(app({ readyState: 2 })).get("/health");
expect(resposta.status).toBe(503);
expect(resposta.body.banco).toBe("conectando");
});
it("não exige autenticação", async () => {
const resposta = await request(app()).get("/health"); // sem header nenhum
expect(resposta.status).toBe(200);
});
});
describe("encerramento gracioso", () => {
// `sair` injetado: espionar process.exit de verdade faz o Jest encerrar a
// suíte com a conexão do banco ainda aberta, e o teardown falha.
afterEach(() => {
process.removeAllListeners("SIGTERM");
process.removeAllListeners("SIGINT");
});
it("fecha o servidor e só então libera os recursos", async () => {
const ordem = [];
const servidorFalso = { close: (cb) => { ordem.push("servidor fechado"); cb(); } };
const fechar = jest.fn(async () => { ordem.push("recursos liberados"); });
const sair = jest.fn();
const log = jest.spyOn(console, "log").mockImplementation(() => {});
const encerrar = encerrarComGraca(servidorFalso, fechar, { sair });
await encerrar("SIGTERM");
await new Promise((r) => setImmediate(r));
expect(ordem).toEqual(["servidor fechado", "recursos liberados"]);
expect(sair).toHaveBeenCalledWith(0);
log.mockRestore();
});
it("dois SIGTERM não encerram duas vezes", async () => {
const fechar = jest.fn(async () => {});
const sair = jest.fn();
const log = jest.spyOn(console, "log").mockImplementation(() => {});
const encerrar = encerrarComGraca({ close: (cb) => cb() }, fechar, { sair });
await Promise.all([encerrar("SIGTERM"), encerrar("SIGTERM")]);
await new Promise((r) => setImmediate(r));
expect(fechar).toHaveBeenCalledTimes(1);
log.mockRestore();
});
});
// 9 — MONITORAMENTO (UptimeRobot)
//
// New Monitor → HTTP(s) → https://sua-api.railway.app/health → 5 minutos.
//
// Só funciona porque o /health diz a verdade: como ele devolve 503 quando o
// banco cai, o UptimeRobot acusa a queda. Se a rota respondesse 200 fixo, o
// painel ficaria verde com a aplicação inteira quebrada.
//
// ---- o que dá para verificar sem sair do terminal
//
// O workflow acima passa no `actionlint` — o mesmo validador que times usam no
// CI para conferir workflow antes de commitar:
//
// npm install -D actionlint
//
// // lint-workflow.mjs
// import { createLinter } from "actionlint";
// import { readFileSync } from "node:fs";
//
// const caminho = ".github/workflows/deploy.yml";
// const lint = await createLinter();
// const problemas = lint(readFileSync(caminho, "utf8"), caminho);
//
// if (problemas.length === 0) console.log("nenhum problema");
// else problemas.forEach((p) => console.log(`${p.line}:${p.column} ${p.message}`));
//
// $ node lint-workflow.mjs
// nenhum problema
//
// E ele pega erro de verdade — trocando `needs: testar` por um job inexistente
// e o runner por um label inválido:
//
// linha 44 [job-needs] job "deploy-api" needs job "teste-que-nao-existe"
// which does not exist in this workflow
// linha 18 [runner-label] label "ubuntu-inventado" is unknown
O /health que devolve 200 fixo é pior que não ter monitoramento: dá a sensação de vigilância sem a vigilância. Ele tem de consultar o que a aplicação precisa para funcionar e devolver 503 quando isso falta — foi por isso que o teste do banco fora do ar entrou aqui. Os outros dois erros que derrubam o primeiro deploy são mais bobos e igualmente comuns: fixar a porta em vez de ler process.env.PORT, o que faz o healthcheck da plataforma nunca passar; e esquecer o rewrites do vercel.json, o que deixa a SPA funcionando pela navegação interna e devolvendo 404 em qualquer F5.
Front-end e back-end vão ao ar por caminhos diferentes porque são coisas diferentes: um vira um punhado de arquivos estáticos servidos por uma CDN, o outro é um processo que precisa continuar rodando. Daí as preocupações se separarem — cache e roteamento de um lado; variável de ambiente, porta, health check e reinício do outro. Já o pipeline que roda os testes antes de publicar existe por uma razão bem prática: é a única barreira que não depende de alguém lembrar.
Fontes e Referências
- Vercel — Documentação: https://vercel.com/docs
- Netlify — Documentação: https://docs.netlify.com
- Railway — Documentação: https://docs.railway.app
- MongoDB Atlas — lista de IPs permitidos: https://www.mongodb.com/docs/atlas/security/ip-access-list/
- GitHub Actions — Documentação: https://docs.github.com/en/actions
- Sentry — Node.js SDK: https://docs.sentry.io/platforms/node
- UptimeRobot: https://uptimerobot.com
- Let's Encrypt: https://letsencrypt.org
- The DevOps Handbook — Gene Kim et al. (IT Revolution Press)
- roadmap.sh — DevOps: https://roadmap.sh/devops
Exercícios
Exercício 1
Para "facilitar", o desenvolvedor pôs a chave do gateway de pagamento no .env do front-end. O arquivo está no .gitignore e a variável tem o prefixo obrigatório. A chave está protegida?
# .env.production (no .gitignore, não vai para o Git)
VITE_API_URL=https://api.meuapp.com
VITE_STRIPE_SECRET_KEY=sk_live_51H...
Ver resposta
✓ Resposta: Não está — a chave é pública no instante em que o build acontece. O Vite não "carrega" variáveis em tempo de execução no navegador: ele faz uma substituição literal durante o build, trocando cada import.meta.env.VITE_STRIPE_SECRET_KEY pelo texto da chave dentro do arquivo JavaScript gerado. Qualquer visitante abre o DevTools, procura no bundle e encontra sk_live_... em texto puro. O .gitignore protegeu o repositório e não protegeu coisa nenhuma além disso. E aqui está a inversão que confunde: o prefixo VITE_ não é uma barreira de segurança, é o oposto — ele é a forma de dizer "esta variável pode ser exposta". O que o prefixo faz é impedir que as demais variáveis do ambiente vazem por acidente. A regra sem exceção é que não existe segredo no front-end: tudo que chega ao navegador é legível. Chave secreta fica no servidor, e o navegador chama a sua API, que por sua vez fala com o gateway. No caso do Stripe isso é explícito na própria nomenclatura: existe uma chave publicável, pk_, feita para o cliente, e uma secreta, sk_, que nunca pode sair do servidor. Se essa chave já foi para um build publicado, revogá-la é a primeira providência.
Exercício 2
O banco de dados caiu às 3h da manhã. A API responde 500 em todas as rotas. O monitor de uptime não alertou ninguém. Por quê?
app.get('/health', (req, res) => {
res.json({
status: 'ok',
uptime: Math.floor(process.uptime()) + 's',
timestamp: new Date().toISOString(),
});
});
Ver resposta
✓ Resposta: Porque este health check só prova que o processo Node está vivo — e ele estava. A rota devolve um objeto montado na hora, sem tocar em nenhuma dependência, então responde 200 alegremente enquanto todas as rotas reais falham por não conseguirem falar com o banco. O monitor perguntou "você está de pé?", a aplicação respondeu "sim", e tecnicamente as duas estavam certas. Um health check útil verifica as dependências das quais a aplicação não consegue funcionar sem: no caso do Mongoose, mongoose.connection.readyState === 1, e responder 503 quando não estiver conectado. O mesmo vale para Redis, fila ou qualquer serviço externo crítico. Vale conhecer a distinção que o mundo de containers faz, porque ela evita um erro comum: a sonda de liveness responde "reiniciar o processo resolveria?" e deve ser simples como esta, sem checar nada externo — se ela falhar por causa do banco, o orquestrador entra num ciclo de reinícios que não conserta nada; já a de readiness responde "posso receber tráfego agora?" e essa sim precisa checar as dependências. Duas rotas, dois propósitos. E um detalhe operacional: o endpoint não deve exigir autenticação, nem devolver informação interna demais — versão de biblioteca e string de conexão num /health público são um presente para quem procura alvo.
Exercício 3
Em produção, toda requisição do front-end falha com erro de CORS — embora a configuração pareça correta e funcione localmente.
const origemPermitida = process.env.FRONTEND_URL || 'http://localhost:5173';
app.use(cors({
origin: origemPermitida,
credentials: true,
}));
// na Vercel, o front está em https://meuapp.vercel.app
// e a variável FRONTEND_URL não foi configurada no Railway
Ver resposta
✓ Resposta: A variável não existe no servidor de produção, então o || entra em ação e o CORS passa a autorizar http://localhost:5173 — um endereço que, em produção, não é a origem de ninguém. O navegador compara a origem real, https://meuapp.vercel.app, com a única permitida, não bate, e bloqueia todas as respostas. O que torna esse defeito traiçoeiro é justamente o valor padrão: ele foi posto para facilitar o desenvolvimento e acabou transformando uma configuração ausente — que deveria estourar no boot, de forma ruidosa — num comportamento errado e silencioso. É o mesmo padrão que aparece com JWT_SECRET e com a URL do banco, e ali as consequências são bem piores. A prática que resolve é validar as variáveis obrigatórias na inicialização e encerrar o processo com mensagem clara quando faltar alguma, exatamente como o artigo de NPM mostrou: é melhor a aplicação não subir do que subir errada. Sobre o CORS em si, dois pontos práticos: em produção costuma-se aceitar uma lista de origens — o domínio principal, o de preview, talvez o de homologação —, o que se faz passando uma função ao origin; e origin: '*' é incompatível com credentials: true, sendo rejeitado pelo navegador, então essa combinação nunca é a saída fácil que parece.
Exercício 4
O pipeline roda os testes e faz o deploy. Um teste falha e o deploy acontece assim mesmo. O que está faltando no arquivo?
jobs:
testes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: railway up
Ver resposta
✓ Resposta: Falta o needs: testes no job de deploy. Por padrão, os jobs de um workflow rodam em paralelo e são independentes: o de deploy nem espera o de testes, quanto mais verifica o resultado dele. O efeito é o pior possível — o pipeline fica verde para quem olha o job de deploy, vermelho no de testes, e o código quebrado vai para produção de qualquer maneira. Com needs: testes, o deploy só começa depois que o outro termina, e é pulado se ele falhar. Vale acrescentar três detalhes que tornam esse pipeline realmente confiável. Primeiro, restringir o deploy à branch principal, com if: github.ref == 'refs/heads/main', senão qualquer branch publica. Segundo, lembrar que needs encadeia jobs, mas dentro de um job os passos já param no primeiro que falha — a menos que alguém tenha escrito continue-on-error, que é a outra causa comum de "o teste falhou e o deploy saiu". Terceiro, e mais importante que tudo: proteger a branch no próprio GitHub, exigindo que a verificação passe antes do merge. O pipeline avisa; a regra de proteção é o que de fato impede.
Exercício 5
O npm run dev funciona perfeitamente. O npm run build quebra com Could not resolve "./components/Header". O arquivo existe e se chama header.jsx. Por quê?
import Header from './components/Header';
Ver resposta
✓ Resposta: Por causa da diferença de maiúsculas e minúsculas entre sistemas de arquivos. Windows e macOS, na configuração padrão, tratam Header e header como o mesmo arquivo; Linux, não — e é em Linux que roda o servidor de build da Vercel, do Netlify e do GitHub Actions. O import funciona na máquina de quem escreveu e não resolve no build, com uma mensagem que parece dizer que o arquivo não existe, quando o que não confere é o nome. É provavelmente a causa número um de "funciona local e quebra no deploy", junto com a variável de ambiente esquecida. Existem duas defesas, e vale ter as duas: "forceConsistentCasingInFileNames": true no tsconfig.json, que faz o TypeScript acusar a divergência ainda no editor, e a regra prática de adotar uma convenção única de nomes — PascalCase para componente, e nunca renomear arquivo só trocando a caixa, porque o Git em Windows costuma não registrar essa mudança. A lição mais geral é a que o artigo já defende: rodar npm run build e npm run preview localmente antes de qualquer push pega esse e vários outros defeitos que o modo de desenvolvimento esconde — import não usado que vira erro, variável ausente, e código que só o bundler de produção analisa.