all posts

How to deploy an Express API with Postgres

Ajay Kumar··9 min read

An Express API with a Postgres database is the most common backend on the internet, and the deployment is genuinely straightforward. What separates a deployment that runs for a year from one you're restarting weekly is a small number of decisions — pool sizing, where migrations run, shutdown behaviour, and how the connection string is delivered.

Here's the whole thing, with the reasoning. The commands are PandaStack's because that's what I build; the decisions apply on any platform.

Step 1: the database first

Create the database before the app, so the connection string exists when the app first starts. An app that boots, fails to connect, and crash-loops is a confusing first impression of a new platform.

# Provision a managed Postgres instance
pandastack db create --label orders-prod --size 1g

# Fetch the connection URL — TLS is required
pandastack db get orders-prod --json | jq -r .connection_url
# postgres://pandastack:<password>@<id>.db.pandastack.ai:5432/pandastack
Size the database by working set, not by total data. Postgres performs well while the indexes and hot rows fit in memory and degrades sharply when they don't. A 16 GB database with a 500 MB working set is happy on a small instance; a 2 GB database that scans everything is not.

Step 2: get the connection pool right

This is the decision that causes the most production incidents in this stack. Postgres connections are processes with real memory cost, so the limit is lower than application developers expect. Your total connection usage is pool size multiplied by number of instances — and that arithmetic is what exhausts the limit during your first traffic spike.

import pg from "pg";

// Pool size is per instance. Four instances at 20 each is 80 connections
// against a database that may only allow 100 — before anything else connects.
const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: Number(process.env.PG_POOL_MAX ?? 10),

  // Return idle connections so a traffic spike doesn't leave you
  // holding the ceiling forever afterwards
  idleTimeoutMillis: 30_000,

  // Fail fast instead of queueing requests behind an exhausted pool
  connectionTimeoutMillis: 5_000,
});

// A pool error with no listener crashes the process in Node
pool.on("error", (err) => console.error("idle client error", err));

Start small — 10 per instance is plenty for most APIs — and raise it only with evidence. A pool that's too large converts a database problem into a much worse database problem, because every instance is now competing for connections that don't exist.

Step 3: run migrations in exactly one place

Not in the start command. If migrations run when the app boots, every instance runs them simultaneously on every restart, and a failed migration turns into a crash-looping production app rather than a blocked deploy.

Run them as a discrete step after the build and before traffic shifts, once.

# Build and deploy, then migrate as its own step before the flip
pandastack apps create --name orders-api \
  --git-url https://github.com/acme/orders-api \
  --build-cmd 'npm ci && npm run build' \
  --start-cmd 'node dist/server.js' \
  --env DATABASE_URL="$DATABASE_URL"

pandastack apps exec orders-api -- npm run migrate

# Write migrations to be backwards-compatible with the running version:
# add columns before you use them, remove them a release later

Step 4: shut down gracefully

Every deploy sends SIGTERM. Without a handler, Node exits immediately and every in-flight request is cut — which is why deploys on a busy API produce a small spike of 502s that nobody quite explains.

const server = app.listen(process.env.PORT || 3000, "0.0.0.0");

process.on("SIGTERM", async () => {
  // 1. Stop accepting new connections, let in-flight requests finish
  server.close(async () => {
    // 2. Close the pool so Postgres doesn't hold dead connections
    await pool.end();
    process.exit(0);
  });

  // 3. Don't hang forever if something refuses to finish
  setTimeout(() => process.exit(1), 15_000).unref();
});
Bind to 0.0.0.0, not localhost. An Express app listening on 127.0.0.1 starts cleanly, logs that it is listening, and is completely unreachable from the platform's proxy. It is the most common cause of a deploy that returns 502 with perfectly healthy-looking logs.

Step 5: a health check that means something

A health endpoint returning 200 unconditionally tells you the process is alive, which you already knew. Make it check the dependency that actually fails — but keep it cheap, because it runs constantly.

app.get("/healthz", async (_req, res) => {
  try {
    // Cheap, but proves the pool can actually reach the database
    await pool.query("SELECT 1");
    res.status(200).json({ ok: true });
  } catch (err) {
    res.status(503).json({ ok: false });
  }
});

One caution: if the health check fails when the database is briefly unavailable, the platform may restart every instance simultaneously during a database blip, turning a thirty-second database issue into a full outage. A common compromise is a liveness check that only reports the process, and a separate readiness check that includes the database.

Step 6: treat the connection string as a credential

DATABASE_URL contains a password, and it leaks in more places than people expect: build logs that echo the environment, error messages that include the connection string when a connection fails, crash reports sent to an error tracker, and the output of a debug endpoint someone added once and forgot about.

Three habits cover most of it. Deliver the value through the platform's secret mechanism rather than a committed file or a build argument. Strip connection strings in your error handler before anything is reported, because most database drivers include the full URL in connection errors by default. And know in advance how you would rotate it — if the answer involves editing three places and redeploying two services by hand, write that runbook down now rather than at the moment you need it quickly.

The pre-launch checklist

  1. Deploy, then deploy again while sending traffic. Watch for 502s during the flip — that's your shutdown handling.
  2. Multiply pool size by instance count and compare it to the database's connection limit. Leave headroom for migrations and your own psql session.
  3. Kill the database connection briefly and watch what the API does. It should return 503s and recover, not crash-loop.
  4. Run a migration through the deploy path and confirm a deliberately broken one blocks the release rather than taking production down.
  5. Check that DATABASE_URL is delivered as a secret and doesn't appear in build logs. Connection strings in logs are a real and routine leak.

The short version

Database first, small pool sized against the real ceiling, migrations as a discrete pre-traffic step, SIGTERM handled, bind to 0.0.0.0, and a health check that tests the database without amplifying database blips into outages. That's the whole list — six things, all cheap to do at the start and all expensive to retrofit after the first incident.

Frequently asked questions

How big should my Postgres connection pool be in Node?

Smaller than you think, and sized against the total rather than per instance. Your real usage is pool size multiplied by instance count, so four instances with a pool of twenty is eighty connections before migrations, a psql session, or a background worker connects — against a database that may allow a hundred. Start at around ten per instance for a typical API, set an idle timeout so a spike does not leave you permanently holding the ceiling, and set a connection timeout so requests fail fast instead of queueing invisibly. Raise it only with evidence from pool wait metrics, not on intuition.

Where should database migrations run in a deploy?

As a discrete step after the build and before traffic shifts, executed exactly once. Running them from the application's start command means every instance runs them simultaneously on every restart, which races, and it converts a failed migration from a blocked deploy into a crash-looping production app — a much worse outcome. Write migrations to be backwards-compatible with the currently running version as well: add a column in one release and start reading it in the next, so that at no point does a rollback leave the old code facing a schema it does not understand.

Why does my Express app return 502 after deploying?

Most often it is listening on localhost rather than all interfaces. An app bound to 127.0.0.1 starts cleanly and logs that it is listening, but the platform's proxy cannot reach it, so every request fails while the logs look perfect. Bind to 0.0.0.0 and read the port from the PORT environment variable rather than hard-coding one. The next most likely causes are a health check pointed at a path the app does not serve, which makes the platform pull a working instance out of rotation, and no SIGTERM handler, which cuts in-flight requests during each deploy.

How do I stop deploys from dropping requests?

Handle SIGTERM. On deploy the platform signals your process and then kills it after a grace period, and without a handler Node exits immediately, severing every in-flight request. The correct sequence is to stop accepting new connections with server.close(), let existing requests complete, close the database pool so Postgres is not left holding dead connections, and then exit — with a timeout that forces exit if something hangs. Pair this with a blue-green deploy so the new version is healthy before the old one is signalled, and the visible effect of a deploy becomes nothing at all.

Should my health check query the database?

Have two checks rather than one. A liveness check should report only that the process is alive and responsive, because that is what a restart would fix. A readiness check can include a cheap database query such as SELECT 1, since an instance that cannot reach its database should not receive traffic. Combining them into a single endpoint that the platform uses for restarts is risky: a brief database blip then fails every instance's check at once, the platform restarts them all simultaneously, and a thirty-second database issue becomes a full outage with a thundering herd of reconnections on the other side.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.