Test and ship Express
Prepare production configuration
Review TLS termination, proxy trust, session storage, limits, secrets, dependencies, and runtime version before deployment.
The notes app works on your laptop. That proves the code, not the deployment. Production changes every assumption around the code: there is a proxy, there are two instances, the disk is not yours, and the secret is not in a file.
Before the first deploy I go through one checklist. Every item comes from a boundary we met earlier in the course.
Pin the runtime
Express 5 needs Node 18 or newer, and hosts default to whatever they like. Say which Node you tested with in package.json:
{
"engines": { "node": ">=22" }
}
Add a .node-version file with the major, 22, which most hosts and version managers read.
Set the environment
NODE_ENV=production turns on view caching and stops the default error handler from sending stack traces. The rest of the values from loadConfig() must be injected by the host, never read from a committed .env:
NODE_ENV=production
PORT=3000
SESSION_SECRET=<48 random bytes, from the host's secret store>
DATABASE_URL=postgres://notes:…@db.internal:5432/notes
Generate the secret once with node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" and store it in the platform’s secrets manager. Our config module refuses to start without it.
Walk the boundaries
Then I check each decision against the real topology:
- TLS and proxy. Who terminates HTTPS? One proxy means
trust proxyis1. A CDN in front of it means2. The number equals the hops. - Sessions. Two instances cannot share the memory store. The store points at Redis or Postgres, and the cookie has
secure: true. - Limits. Body limits are per router. The proxy has its own size and timeout, and the two must agree, or the proxy answers before Express does.
- Uploads.
uploads/sits on a persistent volume, or better, files go to object storage. Container disks are wiped on redeploy. - Shutdown. The host’s stop grace period is longer than our ten second timer.
Install cleanly
Install from the lockfile and skip dev dependencies:
npm ci --omit=dev
npm audit --omit=dev
npm ci fails if package-lock.json and package.json disagree, instead of silently resolving something new. npm audit lists known vulnerabilities in what you’re about to ship. I treat a high severity finding as a blocker.
Verify on a staging copy
A checklist is a guess until you run it. Deploy a throwaway copy with production settings:
curl -sI https://staging.notes.flaviocopes.com/login | grep -i set-cookie
curl -s https://staging.notes.flaviocopes.com/api/whoami
The cookie line shows Secure; HttpOnly; SameSite=Lax. The second returns your real public IP, not the proxy’s 10.x address, which proves trust proxy matches the hops. Then log in, redeploy, and confirm you’re still logged in. That’s the session store surviving a restart.
The failure that gets teams here is promoting the laptop setup unchanged because the demo passed. It passes in staging too, until the second instance starts. Run the list. Twenty minutes now replaces a night of debugging later.
Lesson completed