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-tableThat 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 upTwo 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:
- 1Provision the database. Create a managed PostgreSQL (14.x or 16.x available) from the [dashboard](https://dashboard.pandastack.io).
- 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.
- 3Attach the database to the app.
DATABASE_URLis injected automatically — the same variable your pool andnode-pg-migratealready read, so nothing else to configure. - 4Set remaining env vars (API keys,
SESSION_SECRET, and so on) in the dashboard. - 5Push. Every
git pushtriggers 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. - 6Run migrations with
npx node-pg-migrate upas 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.