2026-04-21 | PreviewProof Team

Preview-Ready Node + Express + Postgres: A Reference Stack

nodeexpresspreview environmentspostgresknex

This is the most generic of the Ring 5 stack posts. Plain Node + Express + Postgres is the baseline shape for a substantial number of teams who don’t run a more opinionated framework. NestJS, Hapi, and Fastify teams will find the patterns mostly transferable; the decisions that matter aren’t framework-specific.

The decisions that are worth being explicit about: how Node handles build-time vs runtime env vars (more confusing than other stacks), how to seed Postgres from a JS migration tool like Knex, and how to make Express middleware that depends on the request URL behave correctly when the URL is dynamic per preview.

Reference stack: Node 20 LTS, Express 4, Postgres 16, Knex for migrations and seeding, dotenv for env handling. Deliberately framework-agnostic.

docker-compose.yml: local development

docker-compose.yml
services:
api:
build: .
command: npm run dev
ports:
- "3000:3000"
environment:
NODE_ENV: development
DATABASE_URL: postgres://postgres:postgres@db:5432/app
PORT: "3000"
depends_on:
db: { condition: service_healthy }
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5

docker-compose.yml: preview-ready

Once the stack moves to ephemeral previews, the same docker-compose.yml shifts to use a built image, env-var-injected hostnames, and preview-specific settings:

docker-compose.yml
services:
api:
image: ghcr.io/myorg/api:${GIT_SHA}
command: ./bin/preview-entrypoint.sh
environment:
NODE_ENV: production
DATABASE_URL: postgres://postgres:postgres@db:5432/app_preview
PORT: "3000"
PUBLIC_URL: https://${PREVIEW_HOST}
JWT_SECRET: ${JWT_SECRET}
CORS_ORIGIN: https://${PREVIEW_HOST}
TRUST_PROXY: "1"

NODE_ENV=production matters more than people remember. Express enables view caching, more aggressive error formatting, and certain optimizations only when NODE_ENV is production. Previews should use production to match real-world behavior, not development.

TRUST_PROXY=1 tells Express to trust the first hop reverse proxy — necessary because preview platforms terminate TLS upstream and pass X-Forwarded-Proto. Without it, req.protocol returns http, redirects break, and OAuth callbacks construct insecure URLs.

Build-time vs runtime env vars in Node

This is where Node confuses teams more than other ecosystems. Three layers exist:

  1. process.env at runtime. What you’d expect. Read at request time or at boot.
  2. process.env at build time. Available during npm run build but only for code that reads it during the build itself.
  3. Inlined env vars in bundlers (webpack DefinePlugin, esbuild’s --define). These are baked into the output bundle at build time. They are not env vars at runtime — they’re string literals.

The third one is where teams get bitten. A frontend bundle that reads process.env.API_URL at the top of a module, transformed through a bundler with define, has the value frozen at build time. Changing the runtime env var doesn’t change behavior. For preview environments where the API URL is per-PR, this is fatal.

The Express backend usually doesn’t have this problem (it’s not bundled for distribution). But if you bundle your API with esbuild for cold-start speed, audit your define config — anything platform-specific should come from process.env at runtime, never inlined.

For the broader version of this argument, see environment variables for ephemeral preview environments.

The entrypoint

bin/preview-entrypoint.sh
#!/usr/bin/env bash
set -e
echo "==> Migrating"
npx knex migrate:latest --env production
echo "==> Seeding"
npx knex seed:run --env production --specific=preview.js
echo "==> Booting"
exec node dist/server.js

knex migrate:latest is idempotent. knex seed:run --specific=preview.js runs only the preview seed file, not whatever development seeds you have lying around. Naming the seed file explicitly prevents the “we ran the dev seed in preview by accident” footgun.

A working knexfile.js:

knexfile.js
require("dotenv").config();
module.exports = {
development: {
client: "pg",
connection: process.env.DATABASE_URL,
migrations: { directory: "./db/migrations" },
seeds: { directory: "./db/seeds" },
},
production: {
client: "pg",
connection: process.env.DATABASE_URL,
pool: { min: 2, max: 10 },
migrations: { directory: "./db/migrations" },
seeds: { directory: "./db/seeds" },
},
};

A preview-specific seed file:

db/seeds/preview.js
const { faker } = require("@faker-js/faker");
const bcrypt = require("bcrypt");
faker.seed(42); // deterministic
exports.seed = async (knex) => {
const existing = await knex("users").where({ email: "[email protected]" }).first();
if (existing) return;
const passwordHash = await bcrypt.hash("preview123", 10);
const [admin] = await knex("users")
.insert({ email: "[email protected]", name: "Test Admin", password_hash: passwordHash, role: "admin" })
.returning("*");
const projects = Array.from({ length: 5 }, () => ({
name: `${faker.company.buzzAdjective()} ${faker.company.buzzNoun()}`,
owner_id: admin.id,
}));
await knex("projects").insert(projects);
};

The early-return on an existing admin keeps the seed idempotent. See seeding Postgres for preview environments for richer faker patterns.

Express middleware that depends on request URL

A class of Express middleware constructs URLs (for redirects, for OAuth callbacks, for absolute links in emails). Naive implementations hardcode the hostname. Preview-friendly ones derive it from the request.

middleware/canonicalUrl.js
module.exports = (req, res, next) => {
// req.protocol respects X-Forwarded-Proto when trust proxy is set
res.locals.canonicalUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
next();
};

req.protocol and req.get("host") together produce the actual public URL the user is hitting — works in production, in previews, and locally without configuration. The alternative — reading process.env.PUBLIC_URL — works too but only if the env var is correctly injected per-PR. Deriving from the request is more resilient.

OAuth callback URL construction follows the same pattern; see OAuth callback URLs in ephemeral previews.

CORS in dynamic-URL environments

const cors = require("cors");
app.use(cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true);
if (/^https:\/\/[a-z0-9-]+\.previews\.example\.com$/.test(origin)) return cb(null, true);
if (origin === "http://localhost:3000") return cb(null, true);
cb(new Error("Not allowed by CORS"));
},
credentials: true,
}));

Regex-matching the preview hostname pattern lets you add the entire wildcard once and never touch CORS configuration as new PRs open.

Adapting to NestJS, Fastify, Hapi

The Compose configuration above is identical for any Node framework. The differences:

  • NestJS: command: node dist/main.js, plus a ConfigModule instead of plain process.env. The forRoot({ isGlobal: true }) pattern picks up env vars cleanly.
  • Fastify: command: node dist/server.js, plus app.listen({ host: "0.0.0.0", port: 3000 }). Fastify trusts proxies via app.trustProxy = true rather than an env-var-controlled flag.
  • Hapi: command: node dist/server.js, plus the proxy-trust config in the server options object.

The migration story (Knex), the seed story, and the env var injection pattern are framework-agnostic.

Env template

.env.preview.example
NODE_ENV=production
PORT=3000
DATABASE_URL= # injected by platform
JWT_SECRET= # generated per preview
PUBLIC_URL= # injected: https://pr-123.previews.example.com
CORS_ORIGIN= # injected; same as PUBLIC_URL
TRUST_PROXY=1

Plain Node + Express is forgiving. The traps are around build-time env var inlining (audit your bundler), trust proxy (turn it on), and seed scripts running the wrong file (name them explicitly). Get those right and previews behave — regardless of the preview tool you use to deploy them.

If you’d rather not wire up the per-PR orchestration yourself, PreviewProof consumes Compose-shaped Node stacks like the one above directly, with per-PR env var injection that matches the runtime patterns this post recommends. It’s most useful when previews need to function as a verification surface — sign-off, captured review sessions, audit trail — rather than just a deployment endpoint.