Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

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.

Rodolfo Mori12 min de leitura

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:

js
// 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:

bash
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/health
Marmita Já ouvindo na porta 3000 * Trying [::1]:8777... * Connected to localhost (::1) port 8777 > GET /health HTTP/1.1 > Host: localhost:8777 > * Request completely sent off * Recv failure: Connection reset by peer

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

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

bash
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: responde
{"status":"ok","uptime":32}

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

bash
docker exec marmita-localhost netstat -tln
Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 127.0.0.1:3000 0.0.0.0:* LISTEN

E com 0.0.0.0, que é o que você quer:

bash
docker exec marmita netstat -tln
Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 0.0.0.0:3000 0.0.0.0:* LISTEN

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

bash
npm ci
npm error code EUSAGE npm error npm error The `npm ci` command can only install with an existing package-lock.json or npm error npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or npm error later to generate a package-lock.json file, then try again.

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

bash
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"
4.3M node_modules 65 8.1M node_modules 83

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:

2026-08-22T23:10:27.130Z todas as requisicoes terminaram, saindo npm notice npm notice New major version of npm available! 11.17.0 -> 12.0.2 npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2 npm notice To update run: npm install -g npm@12.0.2

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:

text
node_modules
npm-debug.log
.env
.env.*
.git
.gitignore
Dockerfile*
.dockerignore
coverage
*.md

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

bash
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"'
. .. node_modules package.json src nao existe /app/.env

O Dockerfile da Marmita Já, linha por linha

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

bash
docker build -t marmita-ja:1.0.0 .
#6 [deps 3/4] COPY package.json package-lock.json ./ #6 CACHED #7 [deps 4/4] RUN npm ci --omit=dev #7 CACHED #8 [runner 3/5] COPY --from=deps /app/node_modules ./node_modules #8 CACHED #9 CACHED #10 [runner 4/5] COPY package.json ./ #10 CACHED #11 [runner 5/5] COPY src ./src

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:

bash
docker images marmita-ja
IMAGE ID DISK USAGE CONTENT SIZE EXTRA marmita-ja:1.0.0 669eff6920a8 235MB 59.3MB marmita-ja:ingenuo 315dca2a709a 1.63GB 402MB

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

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

bash
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}}'
t=2s: Up 2 seconds (health: starting) t=7s: Up 7 seconds (healthy) t=16s: Up 16 seconds (healthy)

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.

bash
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}}'
Up 4 seconds (health: starting) Up 8 seconds (health: starting) Up 12 seconds (unhealthy) Up 16 seconds (unhealthy) unhealthy · falhas seguidas: 4

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:

bash
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.log
pedido 42 gravado cat: can't open '/tmp/api.log': No such file or directory

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

js
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);
});
bash
curl -s -X POST http://localhost:8123/pedidos -H 'Content-Type: application/json' \
  -d '{"cliente":"Carla","prato":"Estrogonofe"}'
docker logs marmita
{"id":3,"cliente":"Carla","prato":"Estrogonofe","status":"recebido"} 2026-08-22T23:13:23.024Z Marmita Já ouvindo em http://0.0.0.0:3000 2026-08-22T23:13:30.063Z pedido 3 recebido

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

js
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();
});
bash
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:

2026-08-22T23:09:32.738Z Marmita Já ouvindo na porta 3000 2026-08-22T23:09:34.203Z pedido recebido, gravando... 2026-08-22T23:09:36.252Z SIGTERM recebido: parando de aceitar novas conexoes 2026-08-22T23:09:39.219Z pedido gravado 2026-08-22T23:09:39.236Z todas as requisicoes terminaram, saindo

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:

bash
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)"
PORT lida pelo app: 9000 | NODE_ENV: production

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:

bash
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}}'
DATABASE_URL=postgres://marmita:s3nh4-de-verdade@db.interno:5432/pedidos ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","NODE_VERSION=24.19.0","YARN_VERSION=1.22.22","DATABASE_URL=postgres://marmita:s3nh4-de-verdade@db.interno:5432/pedidos"]

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:

bash
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-loop
reinicios: 5 | ultimo exit: 1 DATABASE_URL nao definida DATABASE_URL nao definida DATABASE_URL nao definida DATABASE_URL nao definida DATABASE_URL nao definida DATABASE_URL nao definida

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

bash
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}' marmita
NAME CPU % MEM USAGE / LIMIT MEM % marmita 0.00% 22MiB / 7.75GiB 0.28%

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

Ver todos os vídeos do canal
  • deploy
  • docker
  • node
  • producao
  • healthcheck
  • logs

Perguntas frequentes

Preciso de Docker para colocar uma API Node no ar?
Não. Várias plataformas detectam o package.json, rodam o script start e pronto. O Docker entra quando você quer que o ambiente de produção seja idêntico e reprodutível, e quando precisa de algo que o buildpack da plataforma não instala.
Qual imagem base usar, alpine ou slim?
Comece pela alpine, que é a menor. Se algum pacote com código nativo falhar ao compilar contra a musl libc — acontece com bibliotecas antigas de imagem e de criptografia —, troque para a variante slim, que usa glibc e ainda é bem menor que a imagem cheia.
O healthcheck deve consultar o banco de dados?
Numa rota só, não. Deixe a rota que a plataforma bate a cada dez segundos respondendo apenas se o processo está vivo. Se quiser checar dependências, crie uma segunda rota e consulte ela sob demanda, não em loop.
Por que o container é reiniciado sozinho de madrugada?
Quase sempre é limite de memória. O processo cresce, estoura o teto do plano e o orquestrador mata com SIGKILL. O sinal disso é o exit code 137 junto de RestartCount subindo — se o container ainda existir, docker inspect mostra OOMKilled como true.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. Docker — Multi-stage builds — docs.docker.com
  2. Docker — Dockerfile reference: HEALTHCHECK — docs.docker.com
  3. Node.js — Process: Signal Events — nodejs.org
  4. npm Docs — npm ci — docs.npmjs.com

Continue por aqui