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

Antes de começar

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.

Sistema Ubuntu 24.04 LTS
RAM mínima 2 GB
Docker 27.x ou superior
Docker Compose 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.

01

Atualize o sistema e instale dependências:

sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-release

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

02

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.gpg
03

Configure 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-plugin
04

Adicione seu usuário ao grupo docker pra não precisar de sudo:

sudo usermod -aG docker $USER
newgrp docker
docker run --rm hello-world

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

05

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.

06

Crie um .dockerignore pra reduzir contexto de build:

node_modules
npm-debug.log
.git
.env
.env.*
dist
coverage
*.md

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

07

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.

Não use python:3.12 sem sufixo

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.

08

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.

09

Suba o stack inteiro:

docker compose up -d --build
docker compose ps
docker compose logs -f api-node

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

Senhas em texto claro no compose

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.

10

Teste os endpoints HTTP da VPS:

curl -i http://localhost:3000/health
curl -i http://localhost:8000/health

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

11

Valide que tudo sobe sozinho após reboot:

sudo reboot

Apó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 que depends_on espere 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.

Tópicos:
Próximos passos Cloud Ryzen com NVMe e proteção DDoS sempre ativa.Coloque em produção numa VPS Hostini →
Esse tutorial foi útil?
Falar no WhatsApp