Deploy de API Node no Brasil: o POST que some em produção
A API Node responde no notebook. Em produção o GET passa e o primeiro POST some. Guia de deploy de API Node no Brasil: env, bind, CORS, proxy, healthcheck e body parser.
Fazer deploy de API Node no Brasil parece resolvido quando o curl localhost:3000/health devolve 200. Você sobe, o painel fica verde, o GET na URL pública responde. O front manda o primeiro POST e ele some. Sem 500 óbvio. Sem log que grite.
A ordem em que essa falha engana: variável que ficou no .env local, processo escutando em localhost, CORS que o browser engole, body vazio atrás do proxy, healthcheck verde numa rota que não é a do POST. No fim, o caminho curto: git push em servidor brasileiro, fatura em Real, 15 dias de teste sem cartão. Comandos e mensagens aqui são os reais da plataforma nesta data.
Se o app é Next.js com API routes, o fluxo é o guia de deploy de Next.js. Aqui o assunto é API Node pura (Express, Fastify ou o http nativo).
O GET verde não prova o POST
O teste que a maioria faz em produção é o mesmo do notebook: abre a URL, vê 200, declara vitória. GET é o verbo mais indulgente da web. Não dispara preflight de CORS, não exige parser de body e não depende da variável que o handler de criação lê. O healthcheck da plataforma, por padrão, nem é HTTP: sem healthCheckPath, a probe é TCP na PORT. Só prova que alguém abriu a porta. GET HTTP só existe quando você configura o caminho.
O POST carrega origem, cabeçalho, corpo e, quase sempre, um segredo. Se qualquer um desses quatro estiver errado, o GET continua verde e o POST some. O Network mostra um OPTIONS 404, um body undefined ou um 401 que o front lê como “não aconteceu”.
Antes de caçar infra, reproduza o POST fora do browser:
curl -i -X POST https://sua-api.upuai.com.br/pedidos \
-H 'content-type: application/json' \
-d '{"ok":true}'
Se o curl passa e o front falha, não é bind nem porta: é CORS ou o jeito que o browser manda o pedido. Se o curl também falha, desça a lista abaixo.
A variável que ficou no .env do notebook
No notebook o dotenv lê o arquivo. Em produção esse arquivo não sobe: o upuai up exclui .env* de propósito, e o git push no repo conectado também não versiona segredo. O GET de health não precisa de banco. O primeiro POST lê process.env.DATABASE_URL e encontra undefined.
Sintoma: health 200, POST 500, log com password authentication failed ou JWT_SECRET is not defined. O deploy está “no ar”. A rota que usa o mundo real, não.
Declare a variável no serviço, não no disco do seu computador:
upuai vars set \
DATABASE_URL=postgres://... \
JWT_SECRET=... \
CORS_ORIGIN=https://seu-front.upuai.com.br \
--yes
upuai redeploy --yes
Variável só entra no próximo deploy. upuai vars list -o json mostra o que o serviço recebe. Postgres gerenciado no mesmo projeto injeta DATABASE_URL sozinho: o guia de PostgreSQL gerenciado cobre o comando, a conexão privada e a migration no releaseCommand.
O teste de 15 dias comporta app e banco (dois serviços) e não tem backup automático. Produção com dado de usuário pede plano pago, e é ele que mantém a API no ar depois dos 15 dias: Starter R$ 19, Plus R$ 49, Pro R$ 99, Business R$ 199, fatura em Real.
Escutar em localhost: o healthcheck mente
A plataforma injeta PORT e espera o processo escutar nela, em 0.0.0.0. Com host 'localhost' ou '127.0.0.1', o processo só é visível de dentro do container. O healthcheck bate de fora, leva connection refused e o deploy falha com a aplicação aparentemente de pé.
A mensagem real é HEALTHCHECK_PORT_NOT_LISTENING: sua aplicação iniciou, mas nada abriu a porta esperada. O conserto é uma linha:
const port = Number(process.env.PORT) || 3000
app.listen(port, '0.0.0.0')
O que quebra é o host em loopback, ou a porta cravada (listen(3000)) ignorando process.env.PORT. O guia de Docker detalha: EXPOSE 3000 é documentação, não vínculo.
Se o GET público já responde, o bind está certo: bind errado derruba os dois verbos, não só o POST.
CORS: o POST que o browser engole
GET simples o browser manda direto. POST com Content-Type: application/json dispara preflight: o browser pergunta OPTIONS e só envia o POST se a resposta trouxer Access-Control-Allow-Origin cobrindo a origem do front. Se o OPTIONS 404, ou se CORS_ORIGIN ainda é http://localhost:5173, o POST nunca sai. O Network mostra erro de CORS, não o status da rota.
No notebook os dois rodam na mesma origem, ou o cors() está com origin: true “só para desenvolver”. Em produção o front está em app.seudominio.com e a API em api.seudominio.com.
Express, o mínimo que funciona:
import cors from 'cors'
app.use(
cors({
origin: process.env.CORS_ORIGIN,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
}),
)
app.options('*', cors())
Fastify: @fastify/cors com o mesmo origin da env. A origem é exata (esquema, host e porta). https://app.seudominio.com não libera https://www.seudominio.com. CORS_ORIGIN=* com cookie o browser recusa.
Confirme o OPTIONS antes de culpar a rota:
curl -i -X OPTIONS https://sua-api.upuai.com.br/pedidos \
-H 'origin: https://seu-front.upuai.com.br' \
-H 'access-control-request-method: POST'
Se a resposta não trouxer access-control-allow-origin apontando para o front, o POST do browser continua sumindo com a API “funcionando”.
O proxy na frente e o body que chega vazio
Em produção a API não é o primeiro hop: tem um proxy terminando TLS na frente. Três efeitos fingem que o POST “não chegou”.
1. Body parser. Express 4 não lê JSON sozinho. Sem app.use(express.json()), req.body é undefined e o front acha que o POST sumiu. Fastify já parseia JSON; o que quebra é um Content-Type que ele não reconhece. O teste é o curl com -H 'content-type: application/json': se o log mostra o body, o parser está no lugar.
2. trust proxy. Sem app.set('trust proxy', 1) no Express, req.secure é falso, cookie secure não grava e o redirect interno vai para http://. O POST de login passa no curl e falha no browser. Fastify: trustProxy: true na construção do server.
3. Tamanho. O limite default do Express (100 kb) devolve 413 num upload que no notebook passava. O 413 às vezes some atrás de um HTML genérico de proxy. Olhe o status, não a tela.
Nada disso é a plataforma comendo POST. É o processo Node atrás de um hop que o notebook não tem. upuai logs -n 200 mostra o que chegou: se o POST nem aparece, o browser não enviou (CORS); body vazio é parser; 401/403 é a env do JWT.
Healthcheck verde, rota real 404
Sem healthCheckPath, a probe é TCP na PORT: só prova bind. Com o caminho no toml ou no painel, vira GET HTTP. Default de muita gente: /health devolvendo ok. Isso só prova que algum processo HTTP abriu a porta. Não prova que o processo é a API, nem que a rota do POST foi registrada.
Três jeitos comuns de chegar aqui:
- O
startdopackage.jsonainda énodemon src/index.jsoutsx watch. Em produção o watcher não é o servidor. Declare o comando de verdade noupuai.toml:
#:schema https://upuai.com.br/schemas/upuai-toml-v1.json
[deploy]
startCommand = "node src/server.js"
healthCheckPath = "/health"
-
A rota do POST só é montada quando
NODE_ENV !== 'production'. A plataforma não injetaNODE_ENV(injetaPORT). Se você setarNODE_ENV=productionnoupuai.toml([env]) ou comupuai vars set, oifesconde o POST e a probe (TCP ou GET de health, fora desseif) continua verde. -
O
healthCheckPathaponta para/e a home devolve 200, mas a API vive em/api/v1. Coloque o probe num endpoint que importa e teste o POST com o mesmo prefixo do front.
Se o processo sobe e a probe nunca fica saudável, a mensagem é HEALTHCHECK_FAILED: está de pé, mas não respondeu 2xx no caminho configurado. Boot lento cabe no healthCheckTimeout do upuai.toml (default 300 s, teto 600 s). Timeout maior não conserta rota errada.
upuai config show imprime builder, start, health e root dir.
Subir a API Node no Brasil com git push
Quando a API já responde certo no notebook, o caminho é curto. O builder padrão é o railpack: detecta Node pelo package.json e monta a imagem, sem Dockerfile. A CLI instala via Homebrew (brew tap saiph-ti/upuai-cli && brew install upuai) ou, no Windows, Scoop (scoop bucket add upuai https://github.com/saiph-ti/scoop-upuai-cli e scoop install upuai); depois upuai login.
Crie o projeto conectado ao repositório e dispare o primeiro deploy:
upuai init \
--name minha-api \
--repo sua-org/sua-api \
--branch main \
--framework Node.js \
--yes
upuai vars set CORS_ORIGIN=https://seu-front.upuai.com.br JWT_SECRET=... --yes
upuai deploy --wait --yes
No dashboard o destino é o mesmo: app.upuai.com.br, projeto novo, GitHub App com os repositórios que você escolher (permissão de leitura, não escrita). Daí em diante, git push na branch conectada dispara build e deploy. A URL sai em https://minha-api.upuai.com.br, com SSL. Seu conteúdo (apps, bancos, buckets, volumes) fica no Brasil.
O esqueleto que sobrevive a essa lista:
import express from 'express'
import cors from 'cors'
const app = express()
app.set('trust proxy', 1)
app.use(cors({ origin: process.env.CORS_ORIGIN }))
app.use(express.json())
app.get('/health', (_req, res) => res.status(200).send('ok'))
app.post('/pedidos', (req, res) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({ error: 'body ausente' })
}
return res.status(201).json({ id: 1, ...req.body })
})
const port = Number(process.env.PORT) || 3000
app.listen(port, '0.0.0.0')
Monorepo: --root-dir apps/api no init. Sem git no momento: upuai up --wait --yes empacota a pasta local (e exclui .env*). O caminho canônico é o repositório conectado e o git push. Config em upuai.toml; comandos na CLI.
FAQ
Preciso de Dockerfile para fazer deploy de API Node no Brasil?
Não. O railpack detecta Node pelo package.json. Dockerfile entra por dependência de sistema ou passo de build fora do comum. Ter um Dockerfile no repo não troca o builder sozinho: o padrão continua railpack até você declarar builder = "dockerfile" no upuai.toml.
GET funciona e POST não. Por onde eu começo?
Pelo curl do POST, fora do browser. Se passa, o próximo teste é o OPTIONS com o Origin do front. Se falha, upuai logs -n 200: body vazio é parser; 500 com env undefined é variável que não entrou; 404 é startCommand ou prefixo. Bind e PORT só entram se os dois verbos falham.
O healthcheck passa e a rota real devolve 404. O que é?
A probe só prova que algum HTTP abriu a porta. Confira startCommand (nada de nodemon/watch), se a rota do POST não está atrás de um if de NODE_ENV, e se o front chama o mesmo prefixo. upuai config show imprime o que está valendo.
Como ligo um Postgres nessa API?
upuai add --type database --engine postgres provisiona o banco no mesmo projeto e injeta DATABASE_URL. A migration entra como releaseCommand no upuai.toml (ex.: npx prisma migrate deploy): se falhar, o tráfego não muda. Passo a passo no guia de PostgreSQL gerenciado. O período de teste não tem backup automático.
O período de teste aguenta uma API em produção?
Para testar com tráfego real, sim: 1 projeto, 2 serviços, 4 GB, logs de 12 h, sem cartão, por 15 dias. API mais banco já ocupam os dois serviços. Produção de verdade pede um plano pago ao fim do teste: Starter R$ 19, Plus R$ 49 ou Pro R$ 99. Assinatura paga é no cartão, em Real, sem dólar nem IOF.
Do notebook ao POST que chega
A API que “já funciona” no notebook costuma falhar em produção por uma lista curta, quase nunca por Kubernetes: variável que não entrou, processo no loopback, CORS com origem de localhost, body sem parser, healthcheck verde numa árvore que não é a do POST.
O conserto de cada item é pequeno. O teste de verdade também: conta sem cartão, repositório conectado, git push, um curl de POST na URL pública. Sem Dockerfile, sem YAML, processo no Brasil.
Comece seus 15 dias grátis, sem cartão e suba a API. Se o primeiro POST sumir, você já sabe a ordem em que procurar.
Faça deploy no Brasil em 5 minutos
PaaS brasileira, preços em Real, infraestrutura LGPD-native.
Ver planosArtigos relacionados

Migrar do Railway para a Upuai: do railway.json ao upuai.toml
Guia para levar um app do Railway para a Upuai: o que cada chave do railway.json vira no upuai.toml, variáveis, banco Postgres, domínio e a conta em reais.
Bot de WhatsApp na Cloud API: quando sair da biblioteca não oficial
A conta da API oficial do WhatsApp muda em 1º de outubro de 2026: mensagem de serviço e utilidade dentro da janela de 24h passam a ser cobradas. Quando migrar e o que muda no deploy.
Hospedar bot de WhatsApp 24/7 no Brasil (Baileys ou Venom)
Como hospedar um bot de WhatsApp 24/7 no Brasil com Baileys ou Venom: onde a sessão precisa morar para sobreviver ao restart, o risco real de bloqueio do número e o deploy com git push.