Como fazer deploy de um backend Node.js com Postgres
Siga isso no seu próprio servidor. Comece grátis, sem cartão
“Fazer deploy de um backend Node com Postgres” parece uma tarefa só, mas na verdade são quatro: escrever a API, dar a ela um banco de dados de verdade em vez de um array em memória, mudar o formato desse banco com segurança conforme a aplicação cresce, e garantir que as conexões entre os dois não caiam na primeira vez que duas requisições chegarem ao mesmo tempo. A maioria dos tutoriais de Node com Postgres para nas primeiras duas. Este constrói as quatro: uma API Express apoiada em pg, migrations que rodam do mesmo jeito em todo ambiente, um connection pool dimensionado de propósito e não por acaso, e uma configuração de Docker Compose pro desenvolvimento local que corresponde ao que de fato roda em produção.
O que você vai precisar
- Node.js 22 ou 24 instalado localmente.
- Docker e o plugin do Docker Compose, pro desenvolvimento local e pra validar a configuração antes de ela ir pra qualquer outro lugar.
- Conforto básico com SQL; as migrations abaixo são arquivos SQL simples, não um schema gerado por um ORM.
Passo 1: escrever a API
O Express 5.3.0 é a versão atual, e o pg 8.23.1 é a versão atual do node-postgres, o driver padrão pra falar com o Postgres a partir do Node sem um ORM:
npm init -y
npm install express@^5.3.0 pg@^8.23.1
npm install --save-dev node-pg-migrate@^9.0.0 nodemon@^3.1.0
server.js:
const express = require("express");
const { Pool } = require("pg");
const app = express();
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: Number(process.env.PG_POOL_MAX || 10),
idleTimeoutMillis: 30000,
});
app.get("/health", (req, res) => res.json({ status: "ok" }));
app.get("/widgets", async (req, res) => {
const result = await pool.query(
"SELECT id, name, created_at FROM widgets ORDER BY id DESC LIMIT 20"
);
res.json(result.rows);
});
app.post("/widgets", async (req, res) => {
const { name } = req.body;
if (!name) return res.status(400).json({ error: "name is required" });
const result = await pool.query(
"INSERT INTO widgets (name) VALUES ($1) RETURNING id, name, created_at",
[name]
);
res.status(201).json(result.rows[0]);
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "internal error" });
});
const port = process.env.PORT || 3000;
app.listen(port, "0.0.0.0", () => console.log(`listening on ${port}`));
Como em qualquer aplicação Express 5, os handlers de rota assíncronos acima não precisam de um try/catch em torno das chamadas ao banco: o Express 5 repassa uma promise rejeitada de um handler assíncrono pro middleware de tratamento de erro automaticamente, do mesmo jeito que um throw síncrono faria, então o handler de quatro argumentos no final captura uma query que falhou sem precisar envolver o corpo de cada rota.
Passo 2: escrever migrations de verdade, não um schema que você roda uma vez na mão
Um CREATE TABLE digitado direto no psql funciona exatamente uma vez, a primeira. Na segunda vez que você precisa de uma coluna nova, ou você está editando aquele mesmo comando de memória, ou você já perdeu o controle do que de fato foi aplicado onde. O node-pg-migrate resolve isso mantendo cada mudança como um arquivo numerado e com timestamp que roda em ordem, com um registro do que já foi aplicado guardado no próprio banco.
Crie migrations/1700000000000_create-widgets.js:
exports.up = (pgm) => {
pgm.createTable("widgets", {
id: "id",
name: { type: "text", notNull: true },
created_at: { type: "timestamptz", notNull: true, default: pgm.func("now()") },
});
};
exports.down = (pgm) => {
pgm.dropTable("widgets");
};
Adicione scripts ao package.json:
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"migrate:up": "node-pg-migrate up",
"migrate:down": "node-pg-migrate down"
}
}
O node-pg-migrate lê sua connection string da variável de ambiente DATABASE_URL por padrão, a mesma variável que a própria aplicação usa, então existe exatamente um lugar onde o endereço do banco é configurado. Rode uma vez que o Postgres estiver no ar (Passo 4):
DATABASE_URL=postgres://myapp:mypassword@localhost:5432/myapp npm run migrate:up
Você deve ver widgets listada como aplicada, e \dt dentro do psql deve mostrar a tabela. O ponto de um arquivo de migration em vez de um CREATE TABLE isolado não é formalidade: é que exatamente os mesmos arquivos rodam em desenvolvimento, em CI e em produção, então “o schema de fato foi aplicado em produção” é uma pergunta com uma resposta baseada em arquivo, não em memória.
Passo 3: dimensione o connection pool de propósito
new Pool({ connectionString, max: 10 }) significa que esse único processo Node vai abrir no máximo 10 conexões simultâneas com o Postgres, reaproveitando-as entre requisições em vez de abrir uma nova conexão TCP por query. Esse número não é arbitrário, e errar pra qualquer lado causa um problema real:
- Baixo demais, e requisições ficam na fila esperando por uma conexão livre do pool sob concorrência real, aparecendo como latência que não tem nada a ver com a velocidade da query.
- Alto demais, e isso deixa de ser uma decisão por processo: o próprio Postgres tem uma configuração
max_connections, 100 por padrão, compartilhada entre todo cliente conectado àquele banco. Rode três instâncias dessa aplicação commax: 50cada e você já reservou 150 conexões antes de qualquer outro serviço, execução de migration ou sessão depsqlde administrador tentar se conectar, e o Postgres começa a recusar conexões novas de forma abrupta, em vez de degradar de forma gradual.
Um ponto de partida razoável pra uma aplicação pequena é de 10 a 20 conexões por processo, multiplicado por quantas instâncias da aplicação você de fato roda, verificado contra o max_connections com que o banco está realmente configurado, não um valor assumido. O idleTimeoutMillis: 30000 acima fecha conexões que o pool não está usando depois de 30 segundos, pra que um período tranquilo não mantenha conexões abertas sem motivo.
Passo 4: Docker Compose pro desenvolvimento local
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: devpassword
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 5
web:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgres://myapp:devpassword@db:5432/myapp
PORT: 3000
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
command: npm run dev
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
Publicar a porta do db localmente (diferente de um arquivo compose de produção) é proposital aqui: deixa você conectar um cliente gráfico ou rodar psql da sua própria máquina contra o mesmo banco que a aplicação está usando, o que vale a conveniência extra durante o desenvolvimento. O serviço web monta o diretório do projeto por cima da imagem via bind mount e roda npm run dev (nodemon) em vez de npm start, então editar server.js reinicia o processo sem rebuildar a imagem; o /app/node_modules como seu próprio volume anônimo impede que o bind mount sobreponha o node_modules que o Dockerfile já instalou dentro da imagem.
Suba tudo e rode a migration contra o banco em container:
docker compose up -d
docker compose exec web npm run migrate:up
Depois curl -X POST localhost:3000/widgets -H "Content-Type: application/json" -d '{"name":"first widget"}' seguido de curl localhost:3000/widgets deve retornar a linha que você acabou de inserir.
Passo 5: o Dockerfile pra produção
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
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Validei o formato do compose de produção (esse Dockerfile mais um db e um web sem os bind mounts de desenvolvimento ou a porta do Postgres publicada) com:
docker compose -f /tmp/compose-check/docker-compose.yml config
Ele interpretou tudo corretamente com POSTGRES_PASSWORD e DATABASE_URL definidos no ambiente.
O caminho de deploy
Uma VPS rodando isso com Docker Compose, um pg_dump agendado via cron, e Caddy na frente pro HTTPS funciona e é um caminho bem conhecido; veja fazendo deploy de uma aplicação Express no DigitalOcean pra esse passo a passo completo, incluindo o Caddyfile e as regras de firewall.
O caminho mais rápido é apontar o Railyard pro mesmo repositório. A partir das próprias regras de detecção dele: uma dependência de pg no package.json já basta pro Railyard provisionar automaticamente um banco Postgres gerenciado e injetar DATABASE_URL, sem addon pra adicionar na mão; o script start se torna o processo web, lendo $PORT, que o Railyard atribui do mesmo jeito que o server.js deste guia já espera; e um comando de release (configure como npm run migrate:up nas configurações de deploy da aplicação) roda uma vez antes de cada novo release entrar no ar, então a migration do Passo 2 acontece automaticamente em todo deploy em vez de ser um passo que você precisa lembrar de entrar via SSH e rodar. Veja a documentação do Node.js pras regras exatas de detecção e variáveis e secrets pra entender como DATABASE_URL e outras variáveis injetadas funcionam.
Pro dimensionamento do pool do Passo 3, o console de banco de dados do Railyard tem uma aba Insights que mostra conexões ativas em tempo real contra o max_connections, então em vez de ter que adivinhar se max: 10 por instância é seguro, você acompanha o número real contra o limite real enquanto o tráfego cresce; veja bancos de dados e backups pra backups, restauração point-in-time e o console.
Solução de problemas
ECONNREFUSED ao conectar no Postgres. Dentro do Docker Compose, DATABASE_URL precisa apontar pra db, o nome do serviço, não localhost; localhost dentro do container web significa o próprio container web, que não tem Postgres nenhum rodando dentro dele.
As migrations dizem “já aplicada” mas a coluna não está lá. Isso geralmente significa que um arquivo de migration foi editado depois de já ter rodado uma vez. Ferramentas de migration rastreiam o que foi aplicado por nome de arquivo/timestamp, não comparando o schema real, então uma mudança num arquivo já aplicado passa despercebida; adicione uma migration nova em vez de editar uma antiga, mesmo pra corrigir um erro.
A aplicação funciona bem localmente, mas o Postgres começa a recusar conexões em produção. Isso quase sempre é o problema de dimensionamento do pool do Passo 3: mais instâncias, ou um max por instância mais alto, do que o max_connections realmente permite. Confira as conexões atuais contra o limite (a aba Insights, ou SELECT count(*) FROM pg_stat_activity; contra SHOW max_connections; na mão) antes de recorrer a um plano de banco maior.
Requisições entram numa fila sob carga mesmo com a CPU aparentando estar tranquila. Isso é um pool pequeno demais pra concorrência que você está de fato recebendo, não necessariamente uma query lenta; pool.waitingCount (uma propriedade na instância Pool) diz quantas queries estão esperando no momento por uma conexão livre, que é o número a observar antes de assumir que o próprio banco é o gargalo.
Perguntas frequentes
Eu preciso de Docker pro desenvolvimento local, ou posso simplesmente rodar o Postgres nativamente? O Docker Compose mantém a versão e a configuração do Postgres consistentes entre sua máquina e produção, e é o que este guia usa, mas um Postgres instalado nativamente com DATABASE_URL apontando pra localhost funciona de forma idêntica; nada no server.js ou nas migrations se importa com como o Postgres em si está rodando.
Posso usar um ORM como Prisma ou Drizzle em vez do pg puro e do node-pg-migrate? Sim, e a matemática de connection pooling e max_connections do Passo 3 se aplica exatamente do mesmo jeito, já que a maioria dos ORMs de Node envolve o pg.Pool (ou algo bem equivalente) por baixo em vez de substituí-lo. Tanto o Prisma quanto o Drizzle têm suas próprias ferramentas de migration como alternativa ao node-pg-migrate, se você preferir manter o schema e a camada de query numa única ferramenta.
Qual é um max_connections e um tamanho de pool seguros pra uma aplicação pequena? O Postgres vem com 100 por padrão. Pra uma única instância de aplicação com um pool modesto de 10 a 20, isso deixa bastante margem; o número que realmente importa observar é o total de conexões somando toda instância, execução de migration e sessão de admin, contra o que o banco está de fato configurado, não o padrão isolado.
Por que um comando de release em vez de rodar as migrations dentro do server.js na inicialização? Rodar migrations no código de inicialização do servidor significa que toda réplica que sobe tenta rodá-las, o que é uma abordagem razoável pra aplicações pequenas, mas fica arriscado quando você escala pra mais de uma instância web subindo ao mesmo tempo. Um comando de release roda exatamente uma vez, antes de o tráfego ser roteado pro release novo, o que é o padrão mais seguro quando uma aplicação já tem mais de uma instância.
O banco de dados é a parte de “fazer deploy de um backend Node com Postgres” que é fácil de deixar funcionando e fácil de deixar errado de um jeito que só aparece quando o tráfego real chega. Migrations que são arquivos em vez de memória, e um tamanho de pool que é uma decisão em vez de um padrão, são as duas mudanças que fazem a diferença.
Por que escolhem o Railyard para isso
- Sua própria conta de cloud. Faça deploy no DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou em qualquer servidor Linux via SSH, cobrado direto pelo seu provedor, sem markup.
- Um preço fixo por equipe. O Free é grátis para sempre: um servidor mais um servidor de banco de dados, três apps, sem cartão. O Pro custa US$19 por mês para até cinco servidores e apps ilimitados. O Team custa US$49 por mês com servidores ilimitados. Sem cobrança por app, por deploy, por assento ou por gigabyte.
- Templates de um clique. Faça deploy de apps open source, como bancos de dados, analytics e ferramentas de dev, no seu próprio servidor em um clique.
- Bancos de dados gerenciados. Postgres, MySQL e Redis com backups programados, verificados por restauração, e recuperação point-in-time.
- Push-to-deploy. Faça deploy a partir do GitHub, GitLab ou da CLI, com HTTPS automático e releases sem downtime.