Cómo desplegar un backend de Node.js con Postgres
Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta
“Desplegar un backend de Node con Postgres” suena a una sola tarea, pero en realidad son cuatro: escribir la API, darle una base de datos real en vez de un arreglo en memoria, cambiar la forma de esa base de datos de forma segura a medida que la app crece, y asegurarte de que las conexiones entre ambas no se vengan abajo la primera vez que llegan dos solicitudes a la vez. La mayoría de los tutoriales de Node con Postgres se quedan en las dos primeras. Este construye las cuatro: una API de Express respaldada por pg, migraciones que corren igual en cada entorno, un pool de conexiones dimensionado a propósito en vez de por accidente, y una configuración de Docker Compose para desarrollo local que coincide con lo que realmente corre en producción.
Lo que necesitas
- Node.js 22 o 24 instalado localmente.
- Docker y el plugin de Docker Compose, para desarrollo local y para validar la configuración antes de llevarla a otro lado.
- Comodidad básica con SQL; las migraciones de abajo son simples archivos SQL, no un esquema generado por un ORM.
Paso 1: escribe la API
Express 5.3.0 es la versión actual, y pg 8.23.1 es la versión actual de node-postgres, el driver estándar para hablar con Postgres desde Node sin 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 con cualquier aplicación Express 5, los manejadores de ruta async de arriba no necesitan un try/catch alrededor de sus llamadas a la base de datos: Express 5 reenvía automáticamente una promesa rechazada de un manejador async al middleware de manejo de errores, de la misma forma que lo haría un throw síncrono, así que el manejador de cuatro argumentos al final captura una consulta fallida sin envolver cada cuerpo de ruta.
Paso 2: escribe migraciones reales, no un esquema que corres una vez a mano
Escribir CREATE TABLE directamente en psql funciona exactamente una vez, la primera. La segunda vez que necesitas una columna nueva, o editas ese mismo comando de memoria, o perdiste la cuenta de qué se aplicó realmente y dónde. node-pg-migrate resuelve esto manteniendo cada cambio como un archivo numerado y con marca de tiempo que corre en orden, con un registro de lo que ya se aplicó guardado en la propia base de datos.
Crea 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");
};
Agrega scripts a package.json:
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"migrate:up": "node-pg-migrate up",
"migrate:down": "node-pg-migrate down"
}
}
node-pg-migrate lee su cadena de conexión de la variable de entorno DATABASE_URL por defecto, la misma variable que usa la propia app, así que hay un solo lugar donde se configura la dirección de la base de datos. Córrelo una vez que Postgres esté arriba (Paso 4):
DATABASE_URL=postgres://myapp:micontrasena@localhost:5432/myapp npm run migrate:up
Deberías ver widgets listado como aplicado, y \dt dentro de psql debería mostrar la tabla. El punto de un archivo de migración frente a un CREATE TABLE de una sola vez no es la ceremonia: es que los mismos archivos exactos corren en desarrollo, en CI y en producción, así que “¿realmente se aplicó el esquema en producción?” es una pregunta con una respuesta basada en archivos en vez de en la memoria.
Paso 3: dimensiona el pool de conexiones a propósito
new Pool({ connectionString, max: 10 }) significa que este único proceso de Node abrirá como máximo 10 conexiones simultáneas a Postgres, reutilizándolas entre solicitudes en vez de abrir una conexión TCP nueva por consulta. Ese número no es arbitrario, y equivocarse en cualquiera de las dos direcciones causa un problema real:
- Demasiado bajo, y las solicitudes hacen fila detrás del pool esperando una conexión libre bajo concurrencia real, lo cual se manifiesta como latencia que no tiene nada que ver con la velocidad de las consultas.
- Demasiado alto, y deja de ser una decisión por proceso: Postgres mismo tiene un ajuste
max_connections, 100 por defecto, compartido entre todos los clientes conectados a esa base. Corre tres instancias de esta app conmax: 50cada una y ya reservaste 150 conexiones antes de que un solo otro servicio, una corrida de migración o una sesión depsqlde administración intenten conectarse, y Postgres empieza a rechazar conexiones nuevas directamente en vez de degradarse con gracia.
Un punto de partida razonable para una app pequeña es de 10 a 20 conexiones por proceso, multiplicado por cuantas instancias de la app realmente corren, verificado contra el max_connections con el que la base de datos está realmente configurada, no uno asumido. idleTimeoutMillis: 30000 arriba cierra las conexiones que el pool no está usando después de 30 segundos, para que un periodo tranquilo no mantenga conexiones abiertas sin razón.
Paso 4: Docker Compose para desarrollo 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 el puerto de db localmente (a diferencia de un archivo compose de producción) es deliberado aquí: te deja conectar un cliente gráfico o correr psql desde tu propia máquina contra la misma base de datos que está usando la app, lo cual vale la pena por la comodidad durante el desarrollo. El servicio web monta el directorio del proyecto sobre la imagen y corre npm run dev (nodemon) en vez de npm start, así que editar server.js reinicia el proceso sin reconstruir la imagen; /app/node_modules como su propio volumen anónimo evita que el bind mount tape el node_modules que el Dockerfile ya instaló dentro de la imagen.
Levántalo y corre la migración contra la base de datos en contenedor:
docker compose up -d
docker compose exec web npm run migrate:up
Luego curl -X POST localhost:3000/widgets -H "Content-Type: application/json" -d '{"name":"first widget"}' seguido de curl localhost:3000/widgets debería devolver la fila que acabas de insertar.
Paso 5: el Dockerfile para producción
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"]
Validé la forma del compose de producción (este Dockerfile más un servicio db y web sin los montajes de desarrollo ni el puerto de Postgres publicado) con:
docker compose -f /tmp/compose-check/docker-compose.yml config
Se analizó limpiamente con POSTGRES_PASSWORD y DATABASE_URL definidos en el entorno.
El camino de despliegue
Un VPS corriendo esto con Docker Compose, un pg_dump programado por cron, y Caddy al frente para HTTPS funciona y es un camino bien conocido; consulta desplegar una app de Express en DigitalOcean para ese recorrido completo, incluido el Caddyfile y las reglas de firewall.
El camino más rápido es apuntar Railyard al mismo repositorio. Según sus propias reglas de detección: una dependencia pg en package.json basta para que Railyard aprovisione automáticamente una base Postgres administrada e inyecte DATABASE_URL, sin ningún addon que agregar a mano; el script start se vuelve el proceso web, leyendo $PORT, que Railyard asigna de la misma forma que el server.js de esta guía ya espera; y un comando de release (configúralo como npm run migrate:up en los ajustes de despliegue de la app) corre una vez antes de cada nueva versión, para que la migración del Paso 2 ocurra automáticamente en cada despliegue en vez de ser un paso que tienes que recordar correr por SSH. Consulta la documentación de Node.js para las reglas exactas de detección y variables y secretos para cómo funcionan DATABASE_URL y otras variables inyectadas.
Para el dimensionamiento del pool del Paso 3, la consola de base de datos de Railyard tiene una pestaña Insights que muestra en vivo las conexiones contra max_connections, así que en vez de adivinar si max: 10 por instancia es seguro, puedes observar el número real contra el tope real a medida que crece el tráfico; consulta bases de datos y respaldos para respaldos, restauración a un punto en el tiempo y la consola.
Solución de problemas
ECONNREFUSED al conectar con Postgres. Dentro de Docker Compose, DATABASE_URL debe apuntar a db, el nombre del servicio, no a localhost; localhost dentro del contenedor web se refiere al propio contenedor web, que no tiene Postgres corriendo dentro.
Las migraciones dicen “ya aplicada” pero la columna no está. Esto suele significar que un archivo de migración se editó después de haber corrido ya una vez. Las herramientas de migración rastrean lo aplicado por nombre de archivo/marca de tiempo, no comparando el esquema real, así que un cambio en un archivo ya aplicado le resulta invisible; agrega una migración nueva en vez de editar una antigua, incluso para corregir un error.
La app funciona bien en local pero Postgres empieza a rechazar conexiones en producción. Esto casi siempre es el problema de dimensionamiento del pool del Paso 3: más instancias, o un max más alto por instancia, de lo que max_connections realmente permite. Revisa las conexiones actuales contra el límite (la pestaña Insights, o SELECT count(*) FROM pg_stat_activity; contra SHOW max_connections; a mano) antes de pasar a un plan de base de datos más grande.
Las solicitudes se acumulan bajo carga aunque el CPU se vea bien. Eso es un pool demasiado pequeño para la concurrencia que realmente estás viendo, no necesariamente una consulta lenta; pool.waitingCount (una propiedad de la instancia Pool) indica cuántas consultas están esperando actualmente una conexión libre, el número a vigilar antes de asumir que la base de datos misma es el cuello de botella.
Preguntas frecuentes
¿Necesito Docker para desarrollo local, o puedo simplemente correr Postgres de forma nativa? Docker Compose mantiene la versión y configuración de Postgres consistentes entre tu máquina y producción, y es lo que usa esta guía, pero un Postgres instalado nativamente con DATABASE_URL apuntando a localhost funciona idénticamente; nada en server.js ni en las migraciones le importa cómo está corriendo Postgres.
¿Puedo usar un ORM como Prisma o Drizzle en vez de pg y node-pg-migrate directos? Sí, y las cuentas de pooling de conexiones y max_connections del Paso 3 aplican exactamente igual, ya que la mayoría de los ORM de Node envuelven pg.Pool (o algo equivalente) por debajo en vez de reemplazarlo. Prisma y Drizzle traen su propio herramental de migraciones como alternativa a node-pg-migrate si prefieres mantener el esquema y la capa de consultas en una sola herramienta.
¿Cuál es un max_connections y tamaño de pool seguros para una app pequeña? Postgres por defecto usa 100. Para una sola instancia de app con un pool modesto de 10 a 20, eso deja mucho margen; el número que realmente hay que vigilar es el total de conexiones entre todas las instancias, corridas de migración y sesiones de administración combinadas, contra lo que la base de datos realmente tiene configurado, no el valor por defecto aislado.
¿Por qué un comando de release en vez de correr las migraciones dentro de server.js al arrancar? Correr migraciones en el código de arranque del servidor significa que cada réplica que arranca intenta correrlas, lo cual es razonable para apps pequeñas pero se vuelve riesgoso en cuanto hay más de una instancia web arrancando a la vez. Un comando de release corre exactamente una vez, antes de que el tráfico se enrute a la nueva versión, lo cual es la opción más segura en cuanto una app tiene más de una instancia.
La base de datos es la parte de “desplegar un backend de Node con Postgres” que es fácil hacer funcionar y fácil hacer mal de una forma que no se nota hasta que llega tráfico real. Migraciones que son archivos en vez de memoria, y un tamaño de pool que es una decisión en vez de un valor por defecto, son los dos cambios que marcan la diferencia.
Por qué eligen Railyard para esto
- Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
- Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
- Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
- Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
- Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.