Deploy de API Node: porta, env, healthcheck e Dockerfile
O que muda entre a sua máquina e o servidor: porta pela env, start sem watch, healthcheck, logs e um Dockerfile enxuto que builda de verdade.
Fazer deploy de uma API Node é colocar uma versão do serviço em um ambiente onde outra máquina consegue iniciar, alcançar, observar e desligar o processo com segurança. Publicar arquivos é apenas uma parte: porta, variáveis, healthcheck, logs e sinais do sistema operacional formam o contrato de produção.
Pense num restaurante abrindo dentro de um shopping. Não basta levar a cozinha:
é preciso usar a entrada indicada pelo prédio, responder à vistoria, registrar o
que acontece e encerrar o atendimento sem abandonar pedidos pela metade. Na API,
process.env.PORT é a entrada, /health é a vistoria, stdout/stderr são o
registro e SIGTERM inicia o desligamento gracioso. Docker torna esse conjunto
reproduzível, mas não substitui nenhuma dessas responsabilidades.
O exemplo do artigo inteiro é a API da Marmita Já, um restaurante de bairro
que recebe pedidos pelo site. Ela tem duas rotas de negócio (GET /pedidos e
POST /pedidos) e uma rota de saúde. Tudo que está colado aqui saiu de uma
execução real: Docker 29.5.3 num MacBook, com o container rodando Node 24.19.0
sobre node:24-alpine.
O que quebra no primeiro deploy: quase sempre é a porta
Na sua máquina, isso funciona há semanas:
// src/server-porta-fixa.js
import express from 'express';
const app = express();
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(3000, () => {
console.log('Marmita Já ouvindo na porta 3000');
});A plataforma, porém, não pergunta em que porta você gosta de trabalhar. Ela
escolhe uma, coloca em process.env.PORT e manda o tráfego para lá. Dá para
reproduzir isso com um docker run — -e PORT=8080 é a plataforma avisando a
porta, e -p 8777:8080 é ela publicando essa porta para o mundo:
docker run -d --name marmita-fixa -e PORT=8080 -p 8777:8080 \
marmita-ja:1.0.0 node src/server-porta-fixa.js
docker logs marmita-fixa
curl -sv --max-time 5 http://localhost:8777/healthRepare no formato do problema: o container está de pé, o processo está vivo, o log não tem uma linha de erro sequer — e a requisição morre. Na plataforma, isso aparece como um 502 ou como “application failed to respond”. A sua API está ouvindo na 3000; ninguém está falando com a 3000.
process.env.PORT e o 0.0.0.0 que ninguém lembra de colocar
A correção da porta é uma linha, com um valor de reserva para quando você roda local:
const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '0.0.0.0';
const servidor = app.listen(PORT, HOST, () => {
log(`Marmita Já ouvindo em http://${HOST}:${PORT}`);
});Tudo que vem de process.env chega como string, por isso o Number explícito —
esse detalhe e o resto da mecânica estão em
variáveis de ambiente no Node.
O segundo argumento do listen é o que engana mais gente, porque fora do
container ele não faz diferença nenhuma. Dentro, faz toda. Subindo a mesma
imagem com HOST=127.0.0.1:
docker run -d --name marmita-localhost -e HOST=127.0.0.1 -p 8456:3000 marmita-ja:1.0.0
curl -s --max-time 5 http://localhost:8456/health # de fora: nada
docker exec marmita-localhost wget -q -O - http://127.0.0.1:3000/health # de dentro: respondeO processo está saudável, responde perfeitamente — para quem já está dentro do
container. De fora, a conexão é cortada. O netstat explica em uma linha. Com
HOST=127.0.0.1:
docker exec marmita-localhost netstat -tlnE com 0.0.0.0, que é o que você quer:
docker exec marmita netstat -tlnSe a mensagem que você recebe é EADDRINUSE em vez de silêncio, o diagnóstico é
outro e está em
EADDRINUSE: address already in use.
npm ci, npm start e o que não pode entrar na imagem
Em produção, npm install é a escolha errada por três motivos, e o primeiro é
que ele pode mudar as versões. npm ci instala exatamente o que está no
package-lock.json — e recusa a trabalhar sem ele:
npm ciEsse erro é uma boa notícia: ele acontece no build, não às três da manhã com uma
versão nova de uma dependência transitiva. Commite o package-lock.json.
O segundo motivo é --omit=dev. Sua API não precisa do supertest nem do ESLint
para servir pedido. Medindo o node_modules dentro de cada imagem:
docker run --rm marmita-ja:1.0.0 sh -c "du -sh node_modules && ls node_modules | wc -l"
docker run --rm marmita-ja:ingenuo sh -c "du -sh node_modules && ls node_modules | wc -l"O terceiro é o CMD. Chamar a API por um script do npm funciona — o npm 11
repassa o SIGTERM direitinho —, mas coloca um processo a mais entre o
orquestrador e a sua API, e joga o barulho do npm no mesmo lugar onde você
procura os seus logs de produção. Este é o fim do log de um container que subiu
com npm run:
Chame o Node direto: CMD ["node", "src/server.js"]. E nada de --watch — ele
existe para reiniciar quando você salva um arquivo, e em produção ninguém salva
arquivo nenhum.
Falta dizer o que não entra na imagem. É o .dockerignore, irmão do
.gitignore:
node_modules
npm-debug.log
.env
.env.*
.git
.gitignore
Dockerfile*
.dockerignore
coverage
*.mdO node_modules local sai porque ele foi instalado para o seu sistema
operacional, não para o Linux do container. O .env sai porque segredo não mora
em imagem. Dá para conferir os dois:
docker run --rm marmita-ja:1.0.0 ls -a /app
docker run --rm marmita-ja:1.0.0 sh -c 'test -f /app/.env && echo "ACHOU o .env" || echo "nao existe /app/.env"'O Dockerfile da Marmita Já, linha por linha
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:24-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "src/server.js"]São dois estágios. O deps existe só para instalar; o runner copia o
resultado e joga fora o resto. USER node faz a API rodar sem ser root, e
EXPOSE é documentação — quem publica a porta de verdade é o -p ou a
plataforma.
A ordem do COPY não é estética. Copiar package.json antes do código faz
o Docker reaproveitar a camada de instalação quando só o código muda. Depois de
mexer em src/server.js e buildar de novo:
docker build -t marmita-ja:1.0.0 .O npm ci veio de cache; só a última camada foi refeita. Invertendo a ordem,
todo commit reinstala tudo.
O resultado aparece no tamanho. A primeira linha é o multi-stage acima; a
segunda, um Dockerfile ingênuo com FROM node:24, COPY . . e npm install:
docker images marmita-jaDe 1,63 GB para 235 MB em disco, e de 402 MB para 59,3 MB de conteúdo — que é o que sobe e desce da rede a cada deploy. Quase sete vezes menos coisa para transferir, sem mudar uma vírgula da API.
Healthcheck: a rota que a plataforma fica batendo sozinha
A rota de saúde é a única que não é feita para uma pessoa. Ela responde a um robô, a cada poucos segundos, para sempre. Então ela precisa ser barata:
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: Math.round(process.uptime()) });
});Subindo o container com o HEALTHCHECK do Dockerfile, o docker ps conta a
história:
docker run -d --name marmita -p 8123:3000 marmita-ja:1.0.0
# e, a cada poucos segundos:
docker ps --filter name=marmita --format '{{.Status}}'O --start-period=5s é o que segura o julgamento enquanto a API sobe. Sem ele,
uma API que demora quatro segundos para abrir conexão com o banco seria
declarada doente antes do primeiro pedido chegar.
Agora o contrário: um healthcheck apontando para uma rota que não existe, com duas tentativas e intervalo de três segundos.
docker run -d --name mj-doente --health-cmd "node -e \"fetch('http://127.0.0.1:3000/saude').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
--health-interval 3s --health-retries 2 --health-start-period 1s marmita-ja:1.0.0
docker ps --filter name=mj-doente --format '{{.Status}}' # a cada 4 segundos
docker inspect mj-doente --format '{{.State.Health.Status}} · falhas seguidas: {{.State.Health.FailingStreak}}'Duas coisas importam aqui. A primeira: o container continua Up enquanto
doente — o Docker sozinho não reinicia nada, ele só carimba o estado. Quem age é
o orquestrador ou a plataforma, que para de mandar tráfego e derruba a instância.
A segunda: nesse teste a API estava perfeita; GET /health respondia 200 e
GET /saude respondia 404. Um healthcheck mal apontado tira do ar uma API que
está funcionando. Confira o caminho antes de dormir tranquilo — e escolha o
status certo, que é assunto de
métodos HTTP e status code.
Log vai para o stdout, não para um arquivo dentro do container
Em produção, console.log é a ferramenta certa: a plataforma lê o stdout do
processo e guarda por você. Gravar num arquivo dentro do container é jogar fora.
Veja o que acontece quando o container é substituído — que é exatamente o que um
deploy faz:
docker run -d --name mj-log marmita-ja:1.0.0 \
node -e "require('node:fs').appendFileSync('/tmp/api.log','pedido 42 gravado\n');setInterval(()=>{},1000)"
docker exec mj-log cat /tmp/api.log
docker rm -f mj-log && docker run -d --name mj-log marmita-ja:1.0.0 node -e "setInterval(()=>{},1000)"
docker exec mj-log cat /tmp/api.logO arquivo existiu, foi lido e sumiu junto com o container antigo. Escrevendo no stdout, com carimbo de hora em cada linha, o mesmo registro sobrevive:
const log = (msg) => console.log(`${new Date().toISOString()} ${msg}`);
app.post('/pedidos', (req, res) => {
const pedido = { id: pedidos.length + 1, ...req.body, status: 'recebido' };
pedidos.push(pedido);
log(`pedido ${pedido.id} recebido`);
res.status(201).json(pedido);
});curl -s -X POST http://localhost:8123/pedidos -H 'Content-Type: application/json' \
-d '{"cliente":"Carla","prato":"Estrogonofe"}'
docker logs marmitaDesligar direito: SIGTERM e o pedido que estava no meio
Todo deploy mata a versão antiga. O jeito de matar é um sinal: SIGTERM, com um
prazo de tolerância. Se o processo não sair sozinho até o fim do prazo, vem o
SIGKILL, que não dá para interceptar.
Para medir isso, a rota POST /pedidos ganhou um setTimeout de cinco
segundos, simulando a gravação do pedido. O roteiro é sempre o mesmo: manda o
pedido, espera dois segundos, e dá docker stop -t 30 com o pedido no meio do
caminho.
process.on('SIGTERM', () => {
log('SIGTERM recebido: parando de aceitar novas conexoes');
servidor.close(() => {
log('todas as requisicoes terminaram, saindo');
process.exit(0);
});
setTimeout(() => {
log('demorou demais, saindo a forca');
process.exit(1);
}, 15000).unref();
});docker run -d --name mj-ok -p 8456:3000 marmita-ja:1.0.0
curl -X POST http://localhost:8456/pedidos -d '{"cliente":"Diego","prato":"Lasanha"}' &
sleep 2
docker stop -t 30 mj-ok
docker inspect mj-ok --format '{{.State.ExitCode}}'Quatro variações do mesmo container, medidas na mesma máquina:
| variação | docker stop -t 30 levou |
exit code | o cliente recebeu |
|---|---|---|---|
node como PID 1, sem handler |
30,3 s | 137 (SIGKILL) | 201, o pedido terminou |
| handler que só escreve no log | 30,3 s | 137 (SIGKILL) | 201, o pedido terminou |
--init, sem handler |
0,3 s | 143 (SIGTERM) | nada, conexão cortada |
--init + servidor.close() |
3,3 s | 0 | 201, o pedido terminou |
As duas primeiras linhas são a armadilha do PID 1: quando o Node é o processo número 1 do container, o Linux ignora o SIGTERM se não houver handler registrado. O container não sai, o prazo inteiro é consumido e ele morre de SIGKILL. Trinta segundos a mais em cada deploy, multiplicados por cada instância.
A terceira linha é o mesmo container com --init, que põe um gerenciador de
processos na frente e tira o Node do posto de PID 1. Aí o SIGTERM funciona como
manda o manual — e o resultado é pior para o cliente: o processo morre na hora,
o pedido de quem estava esperando morre junto, o curl volta com a conexão
cortada e nada foi gravado. Você não controla qual dos dois mundos a sua
plataforma usa, e é justamente por isso que o handler não é opcional.
A quarta é o desligamento gracioso. Este é o log inteiro, com os carimbos:
Leia a sequência: o sinal chegou às 36,25 — dois segundos depois do pedido entrar. O servidor parou de aceitar conexão nova na mesma hora, mas esperou o pedido em andamento terminar às 39,21, e só então saiu, com exit code 0. Três segundos e trezentos milissegundos, exatamente o que faltava do pedido.
O setTimeout de quinze segundos é o seguro contra uma conexão que nunca fecha.
O .unref() no fim dele importa: sem isso, o próprio timer segura o processo
vivo pelos quinze segundos, mesmo depois de todo mundo ter ido embora.
O mesmo handler é o lugar de fechar o pool do banco e de dar flush na fila de
métricas. Erro que escapa e derruba o processo é o outro lado da moeda, e mora
em tratamento de erro centralizado no Express.
Variáveis e segredos: injetar em runtime, nunca no build
Configuração entra pelo ambiente, na hora de rodar:
docker run --rm -e PORT=9000 marmita-ja:1.0.0 \
node -e "console.log('PORT lida pelo app:', process.env.PORT, '| NODE_ENV:', process.env.NODE_ENV)"O caminho tentador — e errado — é passar o segredo como ARG no build. Ele
fica gravado na imagem, e qualquer pessoa com acesso a ela lê sem precisar
executar nada:
docker build --build-arg DATABASE_URL="postgres://marmita:s3nh4-de-verdade@db.interno:5432/pedidos" -t marmita-ja:vazando .
docker history --no-trunc marmita-ja:vazando | grep -o "DATABASE_URL=[^ ]*" | head -1
docker image inspect marmita-ja:vazando --format '{{json .Config.Env}}'docker history guarda o histórico de camadas, e ENV vira metadado da imagem.
Trocar o valor depois não adianta: a camada antiga continua lá. Se um segredo
foi para uma imagem publicada, ele está queimado — troque a senha, não a tag.
Use ARG só para coisa pública, como o número da versão. Segredo entra por
-e, pelo painel da plataforma ou por um gerenciador de segredos, sempre em
runtime.
As primeiras horas depois do deploy
Publicou, respondeu 200, e agora? Três números dizem quase tudo.
Reinícios. Um contador que sobe sozinho é o sintoma mais claro de que algo
está errado no boot. Uma API que sobe sem uma variável obrigatória entra em
loop, e o docker logs mostra a mesma linha repetida:
docker run -d --name mj-loop --restart on-failure:5 marmita-ja:1.0.0 \
node -e "if (!process.env.DATABASE_URL) { console.error('DATABASE_URL nao definida'); process.exit(1); }"
docker inspect mj-loop --format 'reinicios: {{.RestartCount}} | ultimo exit: {{.State.ExitCode}}'
docker logs mj-loopSeis linhas para cinco reinícios: a primeira é o boot original. Valide as
variáveis obrigatórias logo no início do arquivo e falhe alto — um process.exit(1)
com mensagem clara economiza uma hora de investigação.
Memória. Anote o valor de repouso no primeiro dia para ter contra o que comparar depois. A Marmita Já parada, com o healthcheck rodando:
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}' marmitaVinte e dois megabytes. Se daqui a uma semana esse número for 400 MB e nunca descer, você tem um vazamento — normalmente um array global que só cresce, ou um listener registrado dentro de uma rota.
Erros por minuto. Os 5xx são a única métrica que o usuário sente. Vale mais
um console.error com o caminho, o status e o tempo da resposta do que um painel
bonito que ninguém abre.
E antes de tudo isso: a versão que subiu é a que passou nos testes? Rode a suíte no mesmo build, com testes de rota com node:test e supertest.
O que vem depois
Com porta, healthcheck, log e desligamento resolvidos, a sua API deixa de ser um script que roda na sua máquina e vira um serviço. O passo seguinte natural é automatizar: um pipeline que builda a imagem, roda os testes contra ela e só promove a tag se tudo passar.
O mapa completo, com a ordem de estudo e o que vem antes de cada assunto, está no guia de Node.js; a sequência lição a lição fica na trilha de Node.
Faça um ensaio local de produção: construa a imagem, rode-a com uma porta e uma
variável obrigatória injetadas, consulte /health e envie SIGTERM com
docker stop. O teste passa se a porta publicada responde, o healthcheck fica
saudável, o segredo não aparece em docker history e o log mostra o servidor
encerrando depois das requisições em andamento.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Preciso de Docker para colocar uma API Node no ar?
Qual imagem base usar, alpine ou slim?
O healthcheck deve consultar o banco de dados?
Por que o container é reiniciado sozinho de madrugada?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.19.0 em node:24-alpine, Docker 29.5.3, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Docker — Multi-stage builds — docs.docker.com
- Docker — Dockerfile reference: HEALTHCHECK — docs.docker.com
- Node.js — Process: Signal Events — nodejs.org
- npm Docs — npm ci — docs.npmjs.com



