Docker com Node.js do zero: Dockerfile, Compose e os erros que vão te poupar horas
← Voltar para Codeshort

Docker com Node.js do zero: Dockerfile, Compose e os erros que vão te poupar horas

Configure Docker do zero em projetos Node.js: Dockerfile otimizado, Docker Compose com Postgres, hot reload e os 4 erros que todo dev JS comete no início.

DC
Dev Code Software
22 de maio de 2026·9 min de leitura

Sua aplicação funciona perfeitamente no local. No servidor do colega, quebra na instalação. No CI, versão errada do Node. Em staging, variável de ambiente esquecida. Docker não é hype — é a resposta direta pra esse ciclo.

Por que Docker vai mudar seu fluxo de trabalho

A raiz do problema é simples: cada máquina tem um estado diferente. Node.js 18 no seu notebook, 20 no CI, 16 no servidor legado da empresa. npm install instalando versões diferentes dependendo do dia. Uma lib nativa que compila certo no macOS e quebra no Ubuntu.

Docker resolve isso empacotando a aplicação junto com tudo que ela precisa: runtime específico, dependências, configurações de sistema. O resultado é uma imagem — um snapshot imutável do ambiente. Qualquer máquina com Docker instalado roda essa imagem do mesmo jeito, sempre.

Pra dev JavaScript isso tem um impacto direto:

  • Você fixa a versão do Node como parte do repositório, não da máquina de quem vai rodar
  • Dependências de sistema (como node-gyp, canvas, sharp) param de ser um problema de ambiente
  • O ambiente de dev fica idêntico ao de produção — sem mais "funciona na minha máquina"
  • Novos devs no time sobem o projeto inteiro com um único docker compose up

💡 Dica: Você não precisa abandonar o node local. O uso mais pragmático no começo é dockerizar só as dependências externas (banco, Redis, filas) e deixar a API rodando localmente. Vai por partes.

Imagem, container e volume: os três conceitos que importam

Antes de abrir qualquer arquivo, vale ter esses três na cabeça:

Imagem é o template. Você escreve um Dockerfile descrevendo o ambiente e o Docker transforma isso numa imagem. Ela é imutável — você não edita uma imagem que já existe, cria uma nova versão dela.

Container é uma instância em execução de uma imagem. Efêmero por natureza: quando o container para, qualquer dado que estava só na memória dele some junto. Você pode ter dez containers rodando da mesma imagem simultaneamente, cada um isolado.

Volume é a solução pro problema da efemeridade. Ele mapeia um diretório de fora do container (do host ou de um volume gerenciado pelo Docker) para dentro. Banco de dados sem volume reinicia e perde tudo. Com volume, os dados sobrevivem ao ciclo de vida do container.

Dockerfile → docker build → Imagem → docker run → Container
                                                        ↕
                                                    Volume (dados persistem)

Esses três conceitos cobrem 90% do que você vai precisar entender nas próximas semanas.

Instalando Docker no seu sistema

macOS e Windows: baixa o Docker Desktop. Ele instala o Engine, o CLI e o Compose de uma vez. No macOS, existe também o OrbStack — mais leve e mais rápido que o Desktop, vale testar.

Linux: instala o Docker Engine diretamente, sem Desktop:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER

Após instalar (qualquer sistema), verifica:

docker --version
docker compose version

⚠️ Atenção: O comando legado é docker-compose (com hífen, binário separado). O moderno é docker compose (sem hífen, subcomando do CLI). Em projetos mais antigos você vai encontrar os dois. Prefira sempre a versão sem hífen — a com hífen está em modo de manutenção e vai ser descontinuada.

Criando o .dockerignore antes de qualquer coisa

Antes de escrever uma linha de Dockerfile, cria esse arquivo na raiz do projeto. Sem ele, o COPY . . manda pra imagem tudo que está na pasta: node_modules local (que pode ter centenas de MB), .git, arquivos .env com segredos, pasta dist, cache do Next.js.

O build fica lento, a imagem fica pesada e — o pior — segredos podem acabar dentro da imagem que vai pro registry.

node_modules
.git
.env
.env.local
.env.*.local
*.log
npm-debug.log*
dist
build
coverage
.next
.next/cache
.turbo
*.test.js
*.spec.js
__tests__
.DS_Store

Se você usa TypeScript e faz build antes do container, adiciona src na lista — a imagem final só precisa do JavaScript compilado, não do source.

Dockerfile Node.js: do ruim ao que vai pra produção

Pega qualquer API Node.js ou Express como base. O Dockerfile que todo tutorial ensina primeiro é esse:

FROM node:latest

WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "index.js"]

Funciona. Vai rodar. Mas vai te dar problema em produção por três razões: node:latest não é determinístico — muda com o tempo e builds diferentes produzem imagens diferentes. npm install instala devDependencies na imagem de produção. E sem .dockerignore configurado antes, você acabou de copiar o node_modules local pra dentro do container.

Aqui está a versão que você vai querer usar:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

EXPOSE 3000
CMD ["node", "index.js"]

As decisões aqui não são arbitrárias:

  • node:20-alpine fixa a versão major e usa Alpine Linux. A diferença de tamanho é real: imagem node:20 pesa ~350MB, node:20-alpine fica em ~50MB
  • npm ci respeita o package-lock.json à risca, diferente do npm install que pode resolver versões diferentes
  • --omit=dev exclui tudo que está em devDependencies — jest, eslint, typescript, ts-node e cia ficam fora da imagem final
  • A ordem COPY package*.jsonRUN npm ciCOPY . não é acidente. Docker cacheia cada instrução como uma layer. Se você só alterar o código-fonte sem mexer nas dependências, o npm ci não roda de novo — aproveita o cache. Em projetos com 300+ dependências, isso é a diferença entre 4 minutos de build e 20 segundos

Construindo e rodando

docker build -t minha-api:local .

docker run -d \
  -p 3000:3000 \
  --name minha-api \
  minha-api:local

docker ps
docker logs minha-api -f

docker stop minha-api && docker rm minha-api

http://localhost:3000 — seu projeto está rodando em container.

Checklist de produção para o Dockerfile

ItemMotivo
Usar node:XX-alpine em vez de latestBuild determinístico e imagem menor
npm ci em vez de npm installRespeita o lockfile, sem surpresas
--omit=dev no npm ciExclui ferramentas de dev da imagem final
.dockerignore configuradoEvita copiar segredos e arquivos desnecessários
COPY package*.json antes do códigoAproveita cache de layers do Docker

Docker Compose: API + Banco com um comando

Rodar a API isolada tem valor, mas o cenário real é sempre mais complexo: API, banco de dados, talvez Redis ou um worker. Instalar Postgres na máquina local, configurar, lembrar de subir antes de codar — ou usar o Compose e acabar com isso.

O docker-compose.yml define todos os serviços da sua stack e você sobe tudo com docker compose up:

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/meuprojeto
      - NODE_ENV=development
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - .:/app
      - /app/node_modules

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=meuprojeto
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
docker compose up -d
docker compose logs -f
docker compose down
docker compose down -v

O depends_on com condition: service_healthy é onde metade dos tutoriais erra. Sem isso, o container da API sobe e tenta conectar no banco antes do Postgres terminar de inicializar — e a aplicação crasha na inicialização. Com o healthcheck configurado no serviço db, o Compose espera o banco estar respondendo antes de subir a API.

Repara também no volume duplo da API:

volumes:
  - .:/app
  - /app/node_modules

A segunda linha é intencional. Sem ela, o volume .:/app sobrescreve o diretório /app do container inteiro — incluindo o node_modules que foi instalado durante o build. Você perde as dependências. O volume anônimo /app/node_modules cria uma exceção: o Docker mantém esse diretório do container, ignorando o mapeamento do host.

⚠️ Atenção: A porta 5432:5432 no serviço db expõe o Postgres diretamente no host. Isso é conveniente no desenvolvimento — você conecta com localhost:5432 pelo TablePlus ou DBeaver normalmente. Em produção, remova esse mapeamento de porta.

Hot reload dentro do container

O volume .:/app mapeia o código local para dentro do container em tempo real. Você altera um arquivo, o container enxerga a mudança. Mas por si só isso não reinicia o servidor — você precisa de nodemon ou equivalente.

Ajusta o CMD no Dockerfile de desenvolvimento:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

EXPOSE 3000
CMD ["npx", "nodemon", "index.js"]

Ou melhor ainda, usa dois Dockerfiles: um para dev (com nodemon) e um para produção (com node direto e --omit=dev). O Compose aponta pro arquivo certo via build.dockerfile:

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev

Dessa forma o fluxo de desenvolvimento fica: edita o arquivo localmente → nodemon detecta dentro do container → servidor reinicia automaticamente. Sem rebuildar a imagem.

Erros que todo dev JS comete no início

Esquecer o .dockerignore (já falamos, mas vale reforçar)

O erro mais caro em tempo de build. Um node_modules de projeto grande pode ter facilmente 500MB. Sem o .dockerignore, esse volume inteiro é enviado como contexto pro Docker daemon a cada build.

Rodar o processo como root

Por padrão, tudo dentro do container roda como root. Se sua aplicação tiver uma vulnerabilidade e alguém conseguir executar código arbitrário, tem acesso de root dentro do container. Adiciona isso antes do CMD:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

Hardcodar secrets no Dockerfile

ENV DATABASE_URL=postgresql://prod-server:5432/meudb
ENV JWT_SECRET=minha-chave-super-secreta

Qualquer valor definido com ENV vai pra imagem, vai pro registry e fica visível no histórico de layers com docker history. Use --env-file em runtime ou o bloco environment do Compose apontando pra variáveis do host:

environment:
  - DATABASE_URL=${DATABASE_URL}
  - JWT_SECRET=${JWT_SECRET}

Não tratar SIGTERM no processo Node

node index.js não propaga sinais de sistema corretamente. Quando o orquestrador manda SIGTERM pro container (num deploy ou num scale down), o Node não responde e o Docker espera o timeout de 30 segundos antes de matar na força. Isso causa requisições interrompidas e deploys lentos.

Usa --init no docker run:

docker run --init minha-api:local

Ou instala dumb-init no Dockerfile:

RUN apk add --no-cache dumb-init
CMD ["dumb-init", "node", "index.js"]

Esse problema surgiu num deploy em produção depois de a equipe notar que cada rolling update demorava quase um minuto a mais que o esperado. O orquestrador estava esperando o SIGTERM ser processado, não recebia resposta, e matava os containers no timeout. dumb-init encerrou o problema.

FAQ

Preciso usar Docker no desenvolvimento ou só em produção?

Os dois têm valor, mas pra começar o uso mais pragmático é o Compose pra subir dependências (Postgres, Redis, RabbitMQ) sem instalar nada na máquina. Containerizar a própria API durante o dev é opcional — muitos times rodam o Node local com npm run dev e deixam só os serviços no Docker. Isso reduz fricção e mantém o hot reload nativo.

Qual a diferença entre CMD e ENTRYPOINT?

ENTRYPOINT define o executável fixo do container — não é facilmente sobrescrito no docker run. CMD define argumentos padrão que podem ser substituídos. Na prática pra projetos Node: use CMD ["node", "index.js"] pra casos simples. Use ENTRYPOINT quando precisar garantir que um script de inicialização (migrations, health checks) sempre execute antes da aplicação subir.

Como uso variáveis de ambiente sem expor secrets?

Cria um arquivo .env na raiz (que está no .dockerignore e no .gitignore). No Compose, referencia com ${VARIAVEL} no bloco environment. O Compose lê o .env automaticamente se ele estiver na mesma pasta. Pra produção, use secrets do orquestrador (Docker Swarm secrets, Kubernetes secrets, AWS Secrets Manager).

Minha imagem ficou com mais de 800MB. O que aconteceu?

Provavelmente você está usando node:20 sem Alpine, incluindo devDependencies ou copiando node_modules do host. Rode docker image history minha-api:local pra ver qual layer pesa mais. Trocando pra node:20-alpine e adicionando --omit=dev ao npm ci, a maioria dos projetos Express fica entre 100-250MB.

Docker funciona bem com monorepos (Turborepo, Nx)?

Sim, mas o COPY precisa de atenção. O contexto do build precisa incluir o workspace inteiro — você não pode fazer COPY de arquivos fora do contexto. A solução comum é rodar o docker build a partir da raiz do monorepo com -f apps/api/Dockerfile. E o .dockerignore fica na raiz também.

Próximos passos

Com Dockerfile, Compose, hot reload e os erros comuns no bolso, a próxima camada natural é:

TemaPor que importa
Multi-stage buildSepara o ambiente de build do de runtime — essencial pra projetos TypeScript
Health checks no DockerfileKubernetes e ECS dependem disso pra saber se o container está saudável de verdade
Docker networksIsola comunicação entre serviços sem expor portas ao host desnecessariamente
Variáveis por ambienteUm único docker-compose.yml + docker-compose.override.yml pra dev sem duplicação
Publicar no GHCRImagem versionada no GitHub Container Registry pronta pra CI/CD

O próximo post cobre multi-stage build com TypeScript — que é exatamente onde a maioria dos projetos JavaScript acaba chegando na vida real.