NestJS e PostgreSQL na mesma VPS com Docker Compose: como isolar a rede, guardar credenciais e persistir o banco

Diagrama de container NestJS e PostgreSQL isolados em rede interna do Docker Compose numa VPS

Fala aí, beleza? Subir API e banco na mesma VPS parece a parte fácil do projeto, até o dia em que tu percebe que o Postgres estava aceitando conexão de qualquer lugar do mundo 😅

O cenário aqui é bem específico: uma API NestJS e um PostgreSQL no mesmo Docker Compose, com a API falando com o banco pelo nome do serviço (db) na porta do container, a 5432

E o banco NÃO ganha porta publicada no host

É um tutorial de configuração, então quem administra o servidor é você, beleza? Se o seu ponto de partida ainda é o deploy em si, vale ver antes como publicar uma API NestJS na VPS e depois voltar aqui pra fechar o banco

O que você precisa antes de começar

A lista é curta, mas cada item evita uma dor de cabeça lá na frente:

  • Uma VPS (o Docker e o Docker Compose entram no Passo 1, pelo template do hPanel)
  • Acesso root ao servidor
  • Firewall da VPS com a regra de SSH liberada ANTES de qualquer outra coisa
  • Uma aplicação NestJS com TypeORM que já compila para dist/main.js
  • A lista das variáveis de ambiente que a API precisa, definida antes do deploy

Esse último item não é frescura: a documentação de deploy do NestJS lista ter todas as variáveis de ambiente definidas como pré-requisito antes de colocar a API em produção, e a documentação de configuração do framework lembra que, em Node.js, é comum representar cada ambiente com um arquivo .env

Pra este projeto, a recomendação principal aqui do Hora de Codar é a VPS da Hostinger (este artigo contém links de afiliado da Hostinger)

O motivo é técnico e casa com o tutorial: tem acesso root, um template com Ubuntu 24.04 e Docker já instalado, e um firewall gerenciado pelo hPanel que filtra o tráfego antes de ele chegar ao servidor

A página oficial da VPS também lista processadores AMD EPYC e armazenamento NVMe SSD nos planos VPS KVM, o que ajuda quando banco e API dividem a mesma máquina

Passo 1: preparar a VPS e o firewall do hPanel

Antes do Compose, o servidor precisa de Docker e de uma porta de entrada controlada

  1. No hPanel da sua VPS, procure o template de sistema operacional com Docker. Segundo o suporte da Hostinger, esse template traz Ubuntu 24.04 com docker-ce e docker-compose pré-instalados
  2. Abra o firewall em VPS > seu servidor > Segurança > Firewall e crie primeiro a regra que ACEITA o SSH
  3. Depois disso, libere só as portas públicas da API ou do proxy que fica na frente dela. A 5432 não entra nessa lista

Se liga no porquê: esse firewall do hPanel filtra os pacotes antes de o tráfego chegar no servidor, e cada regra tem ação (aceitar, rejeitar ou descartar), protocolo, porta e origem

O erro comum deste passo: ativar o firewall sem liberar o SSH

Como o padrão descarta todo o resto do tráfego de entrada, tu fica trancado pra fora do próprio servidor

Por isso, na prática, é mais seguro garantir a regra de SSH antes de liberar qualquer outra porta 🙂

Passo 2: isolar o PostgreSQL em uma rede interna do Docker Compose

A ideia é simples: o banco só existe numa rede onde mora a API, e mais ninguém

Por padrão, o Compose cria uma rede única pro projeto, com o nome <projeto>_default. Todo serviço entra nela e é encontrado pelo nome, então o host db já resolve pro container do banco (docs de rede do Compose)

Funciona, mas dá pra ser mais restritivo

Se você conhece VLAN de rede corporativa, a lógica é parecida: um segmento fechado pro banco e outro com saída pra fora

services:
  db:
    image: postgres:18
    networks:
      - backend
    # sem bloco ports: o banco não é publicado no host

  api:
    build: .
    environment:
      DB_HOST: db
      DB_PORT: 5432
    networks:
      - backend
      - public

networks:
  backend:
    internal: true
  public:

O que acontece aqui:

  1. A rede backend tem internal: true, então fica isolada, sem conectividade externa
  2. O db está só na backend, sem ports
  3. A api está na backend e na public. Pela rede não interna ela ainda consegue sair pra internet
  4. A API fala com db:5432, que é a porta DO CONTAINER. Na comunicação entre serviços, quem vale é essa porta, não a do host

Resultado esperado: a API alcança o banco e o banco fica sem nenhuma porta publicada na VPS

O erro comum deste passo: colocar ports no db pra conectar um cliente de banco do seu computador

Tome cuidado! Quando tu define uma porta no host, o serviço passa a ser acessível de fora também

E tem um detalhe que pega MUITA gente: segundo a documentação de firewall do Docker, portas publicadas por containers ignoram as regras do ufw, porque o tráfego é roteado na tabela nat antes de chegar nas chains INPUT/OUTPUT que o ufw usa

Ou seja, o ufw diz que está bloqueado e a porta responde mesmo assim

Se o acesso a partir da própria VPS for indispensável, a documentação do Docker oferece três caminhos:

  • Publicar a porta só no loopback, tipo "127.0.0.1:5432:5432"
  • Mudar o endereço padrão de bind das portas publicadas pela chave ip do daemon.json (vale pra rede bridge padrão)
  • Criar suas regras na chain DOCKER-USER, que é processada antes das chains do Docker

Passo 3: guardar a senha do banco com Docker secrets

Senha de banco não deve morar em texto dentro do compose

A documentação de secrets do Compose aponta que passar senha por variável de ambiente traz risco de exposição acidental, e o secret resolve isso montando a senha como arquivo dentro do container

Que arquivo? Ele aparece em /run/secrets/<nome_do_secret>, só nos serviços que pediram aquele secret

services:
  db:
    image: postgres:18
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: appdb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

  api:
    build: .
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ../segredos/db_password.txt
  1. Crie o arquivo da senha fora da pasta do repositório (no exemplo, ../segredos/db_password.txt)
  2. Declare o secret no nível superior, em secrets
  3. Referencie o secret no db e na api pelo atributo secrets de cada serviço
  4. No db, use POSTGRES_PASSWORD_FILE

A imagem oficial do postgres aceita essas variantes _FILE justamente pensando em Docker secrets, e elas também existem pra POSTGRES_USER, POSTGRES_DB e POSTGRES_INITDB_ARGS

Na API, a senha é lida do arquivo pelo seu código (você vê isso no Passo 5)

O erro comum deste passo: manter POSTGRES_PASSWORD em texto no compose ou num .env que foi parar no Git

Aí não adianta nada isolar a rede se a senha está no repositório, né?

Passo 4: persistir os dados com um volume no caminho certo da versão

Sem volume, o banco morre junto com o container

E aqui tem uma pegadinha de versão que vale ouro:

  • Imagem 18 em diante: o PGDATA passou a depender da versão (/var/lib/postgresql/18/docker) e o volume deve ser montado em /var/lib/postgresql
  • Até a versão 17: o ponto de montagem é /var/lib/postgresql/data. Montar o caminho novo (/var/lib/postgresql, sem /data) numa imagem dessa faixa faz os dados irem para um volume anônimo, que não persiste quando o container é recriado
services:
  db:
    image: postgres:18
    volumes:
      - pgdata:/var/lib/postgresql

volumes:
  pgdata:
  1. Use um volume nomeado (no exemplo, pgdata)
  2. Monte no caminho que corresponde à versão da imagem
  3. Fixe a tag da imagem (postgres:18, e não algo genérico), assim o caminho não muda debaixo do seu nariz numa atualização

O erro comum deste passo: montar o caminho da 18+ (/var/lib/postgresql) numa imagem 17 ou anterior

O container sobe normal, mas os dados vão parar fora do volume nomeado que você declarou 😛 Se você está mudando de major version do Postgres, trate isso como uma migração (dump do banco antigo e restore no novo), e não como simples troca de tag no compose: caminho de dados e versão maior do Postgres não se misturam sem um processo de migração

Passo 5: conectar o NestJS ao banco e subir na ordem certa

Agora é juntar tudo: TypeORM apontando pro db, senha vinda do secret e a API esperando o banco ficar saudável

No código abaixo, a configuração do TypeORM usa propriedades como type, host, port, username, password e database, apontando pro banco que subiu no serviço db dentro do mesmo compose

O trecho abaixo é um exemplo funcional: ele lê o arquivo do secret com o fs do Node

import { readFileSync } from 'fs';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: process.env.DB_HOST, // 'db', o nome do serviço
      port: Number(process.env.DB_PORT), // 5432, a porta do container
      username: process.env.DB_USER,
      password: readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trim(),
      database: process.env.DB_NAME,
      synchronize: false,
    }),
  ],
})
export class AppModule {}

E o compose completo, juntando os passos anteriores, fica assim:

services:
  db:
    image: postgres:18
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: appdb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pgdata:/var/lib/postgresql
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "COMANDO_QUE_CONFIRMA_QUE_O_POSTGRES_ACEITA_CONEXAO"]

  api:
    build: .
    command: node dist/main.js
    environment:
      NODE_ENV: production
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: app
      DB_NAME: appdb
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy
    networks:
      - backend
      - public
    # ports:
    #   - "PORTA_PUBLICA:PORTA_DA_API"

networks:
  backend:
    internal: true
  public:

volumes:
  pgdata:

secrets:
  db_password:
    file: ../segredos/db_password.txt

O que cada peça faz:

  1. synchronize: false: a documentação do NestJS avisa que synchronize: true não deve ser usado em produção porque pode causar perda de dados
  2. NODE_ENV: production: a documentação de deploy do NestJS recomenda essa variável em produção, porque algumas bibliotecas mudam de comportamento com ela. O exemplo oficial de inicialização é NODE_ENV=production node dist/main.js
  3. healthcheck no db + depends_on com condition: service_healthy na api: a API só sobe quando o banco estiver saudável
  4. A porta pública da API fica comentada de propósito: preencha com a porta do seu projeto, ou deixe o proxy cuidar disso

O comando do healthcheck está como marcador porque ele depende de como você quer validar o banco no seu ambiente. Escolha o comando, teste e só então confie nele

O erro comum deste passo: deixar synchronize: true copiado do ambiente de desenvolvimento

Em dev é uma mão na roda, em produção é um risco real de perder tabela

Como verificar se o banco ficou fechado e os dados persistem

Bora checar na prática? Essa é a parte que separa "acho que está certo" de "está certo"

  1. Abra o compose e confirme: o db não tem ports e a rede backend tem internal: true
  2. Liste os containers do projeto pelo Compose e confira que o db não aparece com porta publicada no host
  3. Chame um endpoint da API que consulta o banco. Se responder com dados, a conexão pelo host db está funcionando
  4. Grave algum dado pela API, recrie os containers SEM remover os volumes e confira se o dado continua lá
  5. De uma máquina fora da VPS, tente conectar no IP do servidor na porta 5432. A conexão não deve ser aceita
  6. Revise as regras do firewall do hPanel e confirme que só as portas públicas da API ou do proxy estão liberadas

No item 6, se liga: o firewall do hPanel e o firewall do sistema operacional (ufw, por exemplo) são camadas diferentes, que podem ser usadas juntas

O do hPanel filtra os pacotes antes de o tráfego chegar no servidor, já o firewall do SO roda dentro da VPS e, como vimos no Passo 2, não segura porta publicada pelo Docker. Por isso a proteção principal aqui continua sendo o banco não ter porta publicada, e não depender só de regra de firewall pra isso

Problemas comuns: banco exposto, API sem conexão e dados sumindo

Nenhum desses casos pede plano maior: todos são configuração

Por que a porta 5432 responde de fora mesmo com o ufw bloqueando?

Sintoma: o ufw está com a 5432 bloqueada, mas um cliente de fora consegue conectar no banco

Causa: o db tem porta publicada no Docker, e porta publicada contorna o ufw porque passa pela tabela nat antes das chains INPUT/OUTPUT

Solução: remova o bloco ports do db. Se precisar de acesso a partir da própria VPS, faça o bind em 127.0.0.1

Como prevenir: trate qualquer ports no serviço do banco como alerta na revisão do compose

Por que a API NestJS não encontra o PostgreSQL?

Sintoma: a API falha ao conectar no banco logo na inicialização

Causa: o host configurado é diferente do nome do serviço (tipo localhost no lugar de db), ou a api ficou fora da rede backend

Solução: use host: 'db' com a porta do container 5432 e coloque a api na rede backend

Como prevenir: passe host e porta por variável de ambiente e mantenha o nome do serviço igual ao valor de DB_HOST

Por que os dados do PostgreSQL somem depois de recriar o container?

Sintoma: depois de recriar os containers, o banco volta vazio

Causa: volume ausente, ou o caminho novo (/var/lib/postgresql) montado numa imagem 17 ou anterior, que precisa do caminho /var/lib/postgresql/data

Solução: monte o volume em /var/lib/postgresql na imagem 18+ ou em /var/lib/postgresql/data até a 17, conforme a versão que você está usando

Como prevenir: fixe a tag da imagem e confira o ponto de montagem toda vez que mudar de versão

Manutenção: backups e snapshots da VPS

Com o banco fechado e persistindo, o próximo cuidado é ter como voltar atrás

Segundo o suporte da Hostinger sobre backups, a VPS recebe backup automático semanal por padrão, guardado em local separado do servidor

Dá pra criar também um snapshot manual sob demanda, com um snapshot armazenado por vez

Na prática:

  1. Crie um snapshot antes de trocar a versão da imagem do postgres (a troca de caminho entre a 17 e a 18 é exatamente o tipo de mudança que justifica isso)
  2. Lembre que o snapshot novo substitui o anterior, já que só um fica guardado
  3. Mantenha também um dump do banco feito por você, com a ferramenta e a rotina que fizerem sentido pro seu projeto

O backup da VPS protege o servidor inteiro, mas não substitui um backup do banco que você controla e sabe restaurar

Esse backup semanal automático incluído nos planos de VPS da Hostinger é um bom ponto de partida, desde que você não pare nele

Vídeo complementar do canal

Se você curte usar a VPS pra mais coisa além de API, tem um vídeo do canal sobre o Hermes Desktop, a interface que chegou pro Hermes Agent e que roda o agente de IA local ou numa VPS

Vídeo complementar do canal Hora de Codar, sobre um assunto relacionado.

E se você roda agente na VPS também, a lógica de não misturar credenciais vale lá: dá pra separar perfis do Hermes Agent entre trabalho e pessoal

Próximo passo: publicar a API com o banco fechado

Antes de apontar domínio e tráfego real pra API, passe esta checklist no compose:

  • db sem ports
  • rede backend com internal: true
  • senha via secret, com POSTGRES_PASSWORD_FILE
  • volume no caminho certo da versão, com a tag da imagem fixa
  • synchronize: false no TypeORM

Depois disso, libere no firewall do hPanel só as portas públicas da API ou do proxy, e crie um snapshot antes de cada mudança de versão do banco

Se ainda vai escolher onde rodar tudo isso, dá pra conferir a VPS para este projeto já pensando no template Docker e no firewall do hPanel que usamos aqui

Ficou alguma dúvida na configuração? Manda aí nos comentários, bora trocar uma ideia 🙂

Até o próximo post!

Perguntas frequentes

Por que o PostgreSQL não deve ter a porta 5432 publicada no host da VPS?

Porque uma porta publicada pelo Docker fica acessível de fora da rede do Compose, e esse tráfego é roteado pela tabela nat do Docker antes de chegar nas chains do ufw, ou seja, o firewall do sistema não protege essa porta. Mantendo o db só na rede interna, sem bloco ports, o banco fica alcançável apenas pelos outros containers do mesmo projeto, pelo nome do serviço.

Como a API NestJS encontra o PostgreSQL sem expor porta no host?

Pelo DNS interno do Docker Compose: todo serviço do projeto entra na mesma rede e é encontrado pelo nome do serviço, então a API aponta o host do banco para db e usa a porta do container (5432, no caso do Postgres).

Onde devo montar o volume do PostgreSQL para não perder dados ao recriar o container?

Depende da versão da imagem: a partir do Postgres 18, o volume deve ser montado em /var/lib/postgresql; até a versão 17, o ponto de montagem correto é /var/lib/postgresql/data. Montar o caminho novo (/var/lib/postgresql) numa imagem 17 ou anterior faz os dados irem para um volume anônimo, que não persiste quando o container é recriado.

Por que guardar a senha do PostgreSQL em Docker secrets em vez de variável de ambiente?

Porque variáveis de ambiente com senha em texto trazem risco de exposição acidental. O Docker secret monta o valor como arquivo em /run/secrets/<nome_do_secret> apenas nos serviços que o referenciam, e a imagem oficial do postgres já aceita essa senha via POSTGRES_PASSWORD_FILE.


Quer aprender mais sobre inteligência artificial e programação na prática? Conheça o canal Hora de Codar no YouTube e acompanhe os próximos vídeos.

Escrito por | Matheus Battisti

Matheus Battisti
Fundador da Hora de Codar

Programador apaixonado pelo mundo das tecnologias, sempre buscando em aprender e se aprofundar em linguagens, frameworks e o que mais for necessário para executar um bom trabalho. Agora tem uma nova missão que é de passar seu conhecimento adiante para formar novos programadores e especializar mais os que já são.

Formações

Formação Claude Code

Formação Claude Code

Domine Claude Code do absoluto zero até o avançado

  • 120 aulas
  • 4 projetos
  • 9h 48min

Blog | Mais populares