Como rodar Node.js e Python em Docker na VPS: guia prático
Aprenda a rodar Node.js e Python em Docker numa VPS Linux: Dockerfile, docker compose, volumes, redes isoladas e deploy reproduzível.
Rodar aplicações Node.js e Python em Docker numa VPS resolve três problemas que aparecem em qualquer deploy não-trivial: dependências do sistema brigando entre projetos, versão de runtime fixa por host e ambientes que “funcionam na minha máquina” mas explodem em produção. Com Docker, cada aplicação carrega sua própria versão de Node, Python, libs do sistema e configuração — isoladas em contêineres reproduzíveis.
Este guia cobre o fluxo completo: instalar Docker e Docker Compose na VPS, escrever Dockerfiles enxutos pra Node.js (Express/Fastify) e Python (FastAPI/Flask), orquestrar com docker compose, definir redes isoladas pra comunicação entre serviços, persistir dados com volumes e validar que tudo sobe automaticamente após reboot. O foco é em padrões reais de produção — não exemplos de “hello world” que não escalam.
Tempo estimado de execução: 35-45 minutos pra ler, aplicar e validar numa VPS limpa com Ubuntu 24.04 LTS. Se você já tem Docker instalado, pule direto pra seção dos Dockerfiles.
Pré-requisitos
Você precisa de uma VPS Linux com Ubuntu 24.04 LTS (ou Debian 12+), acesso SSH como usuário com sudo, e pelo menos 2 GB de RAM livres pra rodar Node e Python em paralelo. Tenha também o código da aplicação versionado em Git (GitHub, GitLab, ou repo privado) — vamos cloná-lo dentro do build da imagem.
Ubuntu 24.04 LTS 2 GB 27.x ou superior v2 (plugin oficial) Instalando Docker Engine na VPS
A instalação padrão via apt install docker.io traz uma versão desatualizada
do Ubuntu Universe. O caminho correto é usar o repositório oficial da Docker,
que entrega releases atuais e o plugin docker compose v2 integrado.
Atualize o sistema e instale dependências:
sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-releaseEssas dependências são pré-requisito pra adicionar o repositório com chave
GPG verificada — não pulem essa etapa, senão apt recusa o repo.
Adicione a chave GPG oficial do Docker:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpgConfigure o repositório e instale Docker Engine + Compose plugin:
echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-pluginAdicione seu usuário ao grupo docker pra não precisar de sudo:
sudo usermod -aG docker $USER
newgrp docker
docker run --rm hello-worldO hello-world confirma que o daemon está rodando e seu usuário tem
permissão. Se aparecer “Hello from Docker!”, está tudo certo.
Dockerfile pra aplicação Node.js
Pra Node.js, o padrão recomendado é multi-stage build: uma etapa instala dependências e compila (se for TypeScript), outra etapa final só carrega o runtime e os artefatos prontos. Resultado: imagem final 5-10x menor.
No diretório raiz do seu projeto Node, crie o arquivo Dockerfile:
# Stage 1: build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Stage 2: runtime
FROM node:22-alpine
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodeapp -u 1001
COPY --from=builder --chown=nodeapp:nodejs /app /app
USER nodeapp
EXPOSE 3000
CMD ["node", "server.js"]A imagem node:22-alpine pesa ~45 MB contra ~380 MB da node:22. O usuário
não-root (nodeapp) é defense-in-depth — se a aplicação for comprometida,
o atacante não tem root dentro do contêiner.
Crie um .dockerignore pra reduzir contexto de build:
node_modules
npm-debug.log
.git
.env
.env.*
dist
coverage
*.mdSem isso, o Docker copia node_modules local (que pode ter 500 MB+) pra
dentro do contexto, deixando o build lento e a imagem inchada.
Dockerfile pra aplicação Python
Pra Python, o padrão é parecido: imagem base slim, virtualenv isolado ou
instalação direta, e usuário não-root. FastAPI com Uvicorn é o exemplo
moderno; pra Flask ou Django o esqueleto é idêntico, só mudando o CMD.
Crie o Dockerfile no diretório do projeto Python:
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
RUN useradd -m -u 1001 pyapp
COPY --from=builder /root/.local /home/pyapp/.local
COPY --chown=pyapp:pyapp . .
USER pyapp
ENV PATH=/home/pyapp/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]A flag PYTHONUNBUFFERED=1 força stdout/stderr a saírem sem buffer — sem
isso, logs ficam presos em memória e só aparecem quando o processo morre,
o que dificulta debug em produção.
A tag python:3.12 puxa a imagem completa com 1 GB+. Use python:3.12-slim
(150 MB) ou python:3.12-alpine (50 MB). Alpine às vezes quebra pacotes que
dependem de glibc (numpy, pandas com extensões C) — nesse caso, fique no
slim.
Orquestrando com Docker Compose
Em produção real, raramente você roda um único contêiner. O cenário típico é
Node + Python + banco de dados + Redis, todos coordenados. O docker compose resolve isso com um arquivo declarativo.
Na raiz do diretório que contém ambas as aplicações, crie
docker-compose.yml:
services:
api-node:
build: ./node-app
container_name: api-node
restart: unless-stopped
networks:
- backend
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://app:senha@db:5432/appdb
depends_on:
- db
api-python:
build: ./python-app
container_name: api-python
restart: unless-stopped
networks:
- backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://app:senha@db:5432/appdb
depends_on:
- db
db:
image: postgres:16-alpine
container_name: postgres-db
restart: unless-stopped
networks:
- backend
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=senha
- POSTGRES_DB=appdb
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
backend:
driver: bridge
volumes:
postgres-data:As duas aplicações compartilham a rede backend e resolvem o banco pelo
nome db (DNS interno do Docker). Os volumes nomeados persistem dados de
banco entre docker compose down e up.
Suba o stack inteiro:
docker compose up -d --build
docker compose ps
docker compose logs -f api-nodeA flag -d roda em background, --build reconstrói imagens se os
Dockerfiles mudaram. logs -f segue o output em tempo real — Ctrl+C sai
do follow sem parar o contêiner.
O docker-compose.yml acima tem senha hardcoded pra simplificar. Em
produção, use um arquivo .env ao lado do compose com variáveis (ex:
POSTGRES_PASSWORD=...) e referencie como ${POSTGRES_PASSWORD}. Adicione
.env ao .gitignore.
Verificação
Confirme que tudo subiu e está acessível.
Teste os endpoints HTTP da VPS:
curl -i http://localhost:3000/health
curl -i http://localhost:8000/healthCada um deve retornar HTTP/1.1 200 OK com payload da sua aplicação. Se
retornar Connection refused, o contêiner não está escutando na porta
esperada — confira docker compose logs <serviço>.
Valide que tudo sobe sozinho após reboot:
sudo rebootApós reconectar via SSH (espere 30-60s), rode docker compose ps. Os
contêineres devem estar Up por causa do restart: unless-stopped. Se
não, o serviço docker.service pode não estar habilitado no boot — corrija
com sudo systemctl enable docker.
Resolução de problemas
”Cannot connect to the Docker daemon”
Significa que seu usuário não está no grupo docker ou a sessão SSH não
recarregou o grupo. Saia e entre de novo no SSH, ou rode newgrp docker.
Contêiner reinicia em loop (CrashLoopBackOff equivalente)
Rode docker compose logs <serviço> pra ver o erro real. Causas mais
comuns: variável de ambiente faltando, banco não está pronto quando a app
tenta conectar (use depends_on + healthcheck), ou porta já em uso no
host. sudo ss -tlnp | grep <porta> mostra quem ocupa.
Build lento ou disk full
Imagens antigas e contêineres parados acumulam. Limpe com docker system prune -a --volumes. Cuidado: remove TUDO que não está em uso, inclusive
volumes não-referenciados.
Próximos passos
Com Docker funcionando, vale aprofundar em três frentes:
- Reverse proxy com TLS: coloque Caddy ou Nginx na frente dos contêineres pra ter HTTPS automático com Let’s Encrypt e domínio próprio.
- Health checks no compose: adicione
healthcheck:nos serviços pra quedepends_onespere o banco estar pronto, não só “iniciado”. - CI/CD com GitHub Actions: automatize build + push da imagem pro Docker Hub ou GHCR e deploy via SSH na VPS.
Se você está colocando isso em produção, uma VPS Hostini já vem com kernel atualizado e suporte nativo a cgroups v2 — o que evita incompatibilidades com versões recentes do Docker e melhora o isolamento de recursos entre contêineres.
Perguntas frequentes
Posso rodar Node.js e Python no mesmo contêiner?
Tecnicamente sim, mas é anti-pattern. Cada contêiner deve ter um único processo principal (PID 1). O correto é dois contêineres separados na mesma rede Docker, comunicando via DNS interno (o nome do serviço resolve pro IP do contêiner).
Qual a diferença entre COPY e ADD no Dockerfile?
COPY copia arquivos locais pra dentro da imagem — é o que você quase sempre quer. ADD faz a mesma coisa mas também aceita URL HTTP e extrai automaticamente arquivos .tar. Por previsibilidade, use COPY salvo quando precisar explicitamente do auto-extract.
Por que minha aplicação Node não acessa localhost:5432 do Postgres em outro contêiner?
Dentro do contêiner, 'localhost' aponta pro próprio contêiner, não pro host. Use o nome do serviço definido no docker-compose.yml (ex: postgres:5432) ou o nome do contêiner. Eles resolvem via DNS interno do Docker.
Como faço hot-reload do código sem rebuildar a imagem?
Monte o diretório do código como volume bind no docker-compose.yml (ex: ./src:/app/src) e use nodemon (Node) ou uvicorn --reload (Python). Em produção isso não é recomendado — só pra desenvolvimento local.
Preciso de root pra instalar pacotes dentro do contêiner?
Não em produção. Crie um usuário não-root no Dockerfile (USER node ou USER 1001) e rode o processo com ele. Se um pacote precisa root pra instalar, faça antes do USER — instale como root, depois troque pro usuário não-privilegiado pro runtime.
Logs do contêiner enchem o disco. Como resolvo?
Configure log rotation no /etc/docker/daemon.json com driver json-file, max-size e max-file. Exemplo: max-size=10m, max-file=3 limita 30 MB por contêiner. Reinicie o daemon Docker e os novos contêineres respeitam o limite.