How to deploy a Node.js backend with Postgres
Follow this on your own server. Start free, no card needed
“Deploy a Node backend with Postgres” sounds like one task, but it’s really four: write the API, give it a real database instead of an in-memory array, change that database’s shape safely as the app grows, and make sure the connections between the two don’t fall over the first time two requests land at once. Most Node-and-Postgres tutorials stop at the first two. This one builds all four: an Express API backed by pg, migrations that run the same way in every environment, a connection pool sized on purpose rather than by accident, and a Docker Compose setup for local development that matches what actually runs in production.
What you’ll need
- Node.js 22 or 24 installed locally.
- Docker and the Docker Compose plugin, for local development and for validating the setup before it goes anywhere else.
- Basic comfort with SQL; the migrations below are plain SQL files, not an ORM’s generated schema.
Step 1: Write the API
Express 5.3.0 is the current release, and pg 8.23.1 is the current version of node-postgres, the standard driver for talking to Postgres from Node without an 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}`));
As with any Express 5 app, the async route handlers above don’t need a try/catch around their database calls: Express 5 forwards a rejected promise from an async handler to the error-handling middleware automatically, the same way a synchronous throw would be, so the four-argument handler at the bottom catches a failed query without wrapping every route body.
Step 2: Write real migrations, not a schema you run once by hand
CREATE TABLE typed directly into psql works exactly once, the first time. The second time you need a new column, you’re either editing that same command from memory or you’ve lost track of what’s actually been applied where. node-pg-migrate fixes that by keeping each change as a numbered, timestamped file that runs in order, with a record of what’s already been applied kept in the database itself.
Create 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");
};
Add scripts to 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 reads its connection string from the DATABASE_URL environment variable by default, the same variable the app itself uses, so there’s exactly one place the database address is configured. Run it once Postgres is up (Step 4):
DATABASE_URL=postgres://myapp:mypassword@localhost:5432/myapp npm run migrate:up
You should see widgets listed as applied, and \dt inside psql should show the table. The point of a migration file over a one-off CREATE TABLE isn’t ceremony: it’s that the exact same files run in development, in CI, and in production, so “did the schema actually get applied in production” is a question with a file-based answer instead of a memory-based one.
Step 3: Size the connection pool on purpose
new Pool({ connectionString, max: 10 }) means this one Node process will open at most 10 simultaneous connections to Postgres, reusing them across requests rather than opening a new TCP connection per query. That number isn’t arbitrary, and getting it wrong in either direction causes a real problem:
- Too low, and requests queue behind the pool waiting for a free connection under real concurrency, showing up as latency that has nothing to do with query speed.
- Too high, and it stops being a per-process decision at all: Postgres itself has a
max_connectionssetting, 100 by default, shared across every client connected to that database. Run three instances of this app withmax: 50each and you’ve reserved 150 connections before a single other service, migration run, or adminpsqlsession tries to connect, and Postgres starts refusing new connections outright rather than degrading gracefully.
A reasonable starting point for a small app is 10–20 connections per process, multiplied by however many instances of the app you actually run, checked against whatever max_connections the database is actually configured with rather than assumed. idleTimeoutMillis: 30000 above closes connections the pool isn’t using after 30 seconds, so a quiet period doesn’t hold connections open for nothing.
Step 4: Docker Compose for local development
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:
Publishing db’s port locally (unlike in a production compose file) is deliberate here: it lets you connect a GUI client or run psql from your own machine against the same database the app is using, which is worth the extra convenience during development. The web service bind-mounts the project directory over the image and runs npm run dev (nodemon) instead of npm start, so editing server.js restarts the process without rebuilding the image; /app/node_modules as its own anonymous volume stops the bind mount from shadowing the node_modules the Dockerfile already installed inside the image.
Bring it up and run the migration against the containerized database:
docker compose up -d
docker compose exec web npm run migrate:up
Then curl -X POST localhost:3000/widgets -H "Content-Type: application/json" -d '{"name":"first widget"}' followed by curl localhost:3000/widgets should return the row you just inserted.
Step 5: The Dockerfile for production
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"]
I validated the production compose shape (this Dockerfile plus a db and web service without the dev bind mounts or published Postgres port) with:
docker compose -f /tmp/compose-check/docker-compose.yml config
It parsed cleanly with POSTGRES_PASSWORD and DATABASE_URL set in the environment.
The deploy path
A VPS running this with Docker Compose, a cron-scheduled pg_dump, and Caddy in front for HTTPS works and is a well-trodden path; see deploying an Express app to DigitalOcean for that walkthrough end to end, including the Caddyfile and firewall rules.
The faster path is pointing Railyard at the same repository. From its own detection rules: a pg dependency in package.json is enough for Railyard to provision a managed Postgres database automatically and inject DATABASE_URL, no addon to add by hand; the start script becomes the web process, reading $PORT, which Railyard assigns the same way this guide’s server.js already expects; and a release command (set it to npm run migrate:up under the app’s deploy settings) runs once before each new release goes live, so the migration from Step 2 happens automatically on every deploy instead of being a step you remember to SSH in and run. See the Node.js docs for the exact detection rules and variables and secrets for how DATABASE_URL and other injected variables work.
For the pool sizing from Step 3, Railyard’s database console has an Insights tab that shows live connections against max_connections directly, so instead of guessing whether max: 10 per instance is safe, you can watch the actual number against the actual ceiling as traffic grows; see databases and backups for backups, point-in-time restore and the console.
Troubleshooting
ECONNREFUSED connecting to Postgres. Inside Docker Compose, DATABASE_URL needs to point at db, the service name, not localhost; localhost inside the web container means the web container itself, which has no Postgres running in it.
Migrations say “already applied” but the column isn’t there. This usually means a migration file was edited after it already ran once. Migration tools track what’s been applied by filename/timestamp, not by diffing the actual schema, so a change to an already-applied file is invisible to it; add a new migration instead of editing an old one, even to fix a mistake.
The app works fine locally but Postgres starts refusing connections in production. This is almost always the pool-sizing problem from Step 3: more instances, or a higher max per instance, than max_connections actually allows for. Check current connections against the limit (the Insights tab, or SELECT count(*) FROM pg_stat_activity; against SHOW max_connections; by hand) before reaching for a bigger database plan.
Requests queue up under load even though CPU looks fine. That’s a pool that’s too small for the concurrency you’re actually seeing, not a slow query necessarily; pool.waitingCount (a property on the Pool instance) tells you how many queries are currently waiting for a free connection, which is the number to watch before assuming the database itself is the bottleneck.
FAQ
Do I need Docker for local development, or can I just run Postgres natively? Docker Compose keeps the Postgres version and config consistent between your machine and production, and it’s what this guide uses, but a natively installed Postgres with DATABASE_URL pointed at localhost works identically; nothing in server.js or the migrations cares how Postgres itself is running.
Can I use an ORM like Prisma or Drizzle instead of raw pg and node-pg-migrate? Yes, and the connection-pooling and max_connections math in Step 3 applies exactly the same way, since most Node ORMs wrap pg.Pool (or a close equivalent) underneath rather than replacing it. Prisma and Drizzle both ship their own migration tooling as an alternative to node-pg-migrate if you’d rather keep schema and query layer in one tool.
What’s a safe max_connections and pool size for a small app? Postgres defaults to 100. For a single app instance with a modest pool of 10–20, that leaves plenty of headroom; the number to actually watch is total connections across every instance, migration run and admin session combined, against whatever the database is actually configured with, not the default in isolation.
Why a release command instead of running migrations inside server.js on startup? Running migrations in server startup code means every replica that boots tries to run them, which is a reasonable approach for small apps but gets racy once you scale to more than one web instance starting at once. A release command runs exactly once, before traffic is routed to the new release, which is the safer default once an app has more than one instance.
The database is the part of “deploy a Node backend with Postgres” that’s easy to get working and easy to get wrong in a way that doesn’t show up until real traffic arrives. Migrations that are files instead of memory, and a pool size that’s a decision instead of a default, are the two changes that make the difference.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.