Back to Blog
Tutorial7 min read2026-07-11

How to Deploy an Express App with PostgreSQL

Take an Express app to production: port and proxy config, pg connection pooling, node-pg-migrate migrations, a clean Dockerfile, and going live via Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Express has no build step, no CLI, and almost no opinions — which is exactly why "deploying Express" trips people up. The framework leaves every production decision to you: which port to bind, how to sit behind a proxy, how to connect the database, how to run migrations, how to shut down. None of it is hard, but you have to actually do all of it. Here's the full list, end to end.

The parts Express leaves to you

The port. Never hardcode it. Every platform tells your app where to listen via the PORT environment variable:

const port = Number(process.env.PORT) || 3000;

app.listen(port, '0.0.0.0', () => {
  console.log(`listening on ${port}`);
});

Binding to 0.0.0.0 matters in a container — localhost binds the loopback interface only, and the platform's router can't reach it. This is the single most common "works locally, 502 in production" cause.

The proxy. In production your app sits behind at least one reverse proxy, so req.ip, req.protocol, and secure cookies are all wrong unless you tell Express to trust the forwarding headers:

app.set('trust proxy', 1);

Without this, rate limiters see every request coming from the proxy's IP and throttle everyone at once, and Secure cookies silently fail because Express thinks the connection is plain HTTP.

NODE_ENV. Set NODE_ENV=production. Express genuinely behaves differently: view templates get cached and error responses stop leaking stack traces. Most platforms set it for you, but verify rather than assume.

Build and start commands

Plain JavaScript Express has no build step — the start command is just:

{
  "scripts": {
    "start": "node server.js"
  }
}

If you're on TypeScript, compile with tsc and run the output:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js",
    "dev": "tsx watch src/server.ts"
  }
}

with an outDir in tsconfig.json:

{
  "compilerOptions": {
    "outDir": "dist",
    "module": "nodenext",
    "target": "es2022",
    "strict": true
  }
}

Don't run tsx or ts-node in production. Compiling once at build time is faster to boot, uses less memory, and surfaces type errors in the build — where you want them — instead of at runtime.

A health endpoint

app.get('/healthz', (_req, res) => res.status(200).send('ok'));

Keep it dependency-free. If your health check queries the database, a brief database blip gets your perfectly healthy process restarted, which turns a small incident into a bigger one. The health endpoint answers one question: is this process alive and serving?

Connecting PostgreSQL

Use pg with a pool, configured from a single connection string:

const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  ssl: { rejectUnauthorized: false },
});

module.exports = pool;

Two deliberate choices here. First, DATABASE_URL as the one source of truth — it's the convention every managed platform uses, and on PandaStack it's injected into your app automatically when you attach a managed Postgres, so there's nothing to copy between dashboards.

Second, max: 10 is intentional, not a placeholder. Postgres connections are a hard, finite resource. PandaStack's free-tier databases allow 50 connections; a pool of 10 leaves room for a second app instance during a rolling deploy, a migration run, and a psql session for you — all at the same time. Size the pool from the connection limit down, not from optimism up.

Migrations with node-pg-migrate

Express has no ORM, so bring a migration tool. node-pg-migrate is small and reads DATABASE_URL by default, which means zero extra configuration:

npm install node-pg-migrate pg
npx node-pg-migrate create add-users-table

That generates a timestamped file in migrations/:

exports.up = (pgm) => {
  pgm.createTable('users', {
    id: 'id',
    email: { type: 'text', notNull: true, unique: true },
    created_at: {
      type: 'timestamptz',
      notNull: true,
      default: pgm.func('now()'),
    },
  });
};

exports.down = (pgm) => {
  pgm.dropTable('users');
};

Apply with:

npx node-pg-migrate up

Two rules that save you at 2 a.m.: run migrations as a separate step *before* the new version takes traffic — never in the app's boot path, where two instances rolling out together can race on the same migration. And keep migrations forward-only in production; down is for local development.

A Dockerfile for Express

PandaStack can auto-detect a Node app and build it without any Dockerfile, but if you want full control, this multi-stage build is the shape to copy:

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Note the exec-form CMD — with a shell-form CMD node dist/server.js, SIGTERM goes to the shell instead of node and your app never gets to shut down cleanly.

Speaking of which, the minimum viable shutdown handler:

const server = app.listen(port, '0.0.0.0');

process.on('SIGTERM', () => {
  server.close(async () => {
    await pool.end();
    process.exit(0);
  });
  server.closeIdleConnections();
});

This finishes in-flight requests, then releases database connections, then exits. Deploys stop producing mystery 502s.

Deploying on PandaStack

With the app production-ready, going live is mostly pushing:

  1. 1Provision the database. Create a managed PostgreSQL (14.x or 16.x available) from the [dashboard](https://dashboard.pandastack.io).
  2. 2Connect the repo as a container app. PandaStack auto-detects Node.js and figures out the build and start commands; if you committed the Dockerfile above, it uses that instead. The install command is overridable if you're on yarn, pnpm, or bun.
  3. 3Attach the database to the app. DATABASE_URL is injected automatically — the same variable your pool and node-pg-migrate already read, so nothing else to configure.
  4. 4Set remaining env vars (API keys, SESSION_SECRET, and so on) in the dashboard.
  5. 5Push. Every git push triggers a build — rootless BuildKit in an ephemeral Kubernetes job, streamed to you as live build logs — and deploys on success. If a deploy goes sideways, rollbacks restore the previous version.
  6. 6Run migrations with npx node-pg-migrate up as a release step or one-off command against the attached database before the new version serves traffic.

Add a custom domain and SSL certificates are handled automatically.

One free-tier note worth knowing: idle apps scale to zero and cold-start on the next request. Express boots fast, so this is mostly invisible — just keep heavy work (like migrations) out of the boot path, which you should be doing anyway.

That's the whole path: a port read from the environment, a trusted proxy, a right-sized pool, forward-only migrations, and a process that dies politely. If you want to see the git-push-to-live loop with the database wired in for you, try it on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also