Docs menu
Deploying Express
Deploy an Express app on Primcloud: Procfile, port binding, static assets, and common patterns for Express services.
On this page
Express is the closest thing Node.js has to a default web framework, and it deploys on Primcloud with almost no ceremony. Ship a package.json with a start script, bind to $PORT, and the standard Node.js build does the rest. This page covers the Procfile options, the env vars Express services commonly need, and the framework quirks worth knowing before you ship.
Starter template#
Heroku's heroku/nodejs-getting-started repo is a minimal Express app that runs on Primcloud unchanged. Fork it as a starting point:
git clone https://github.com/heroku/nodejs-getting-started
cd nodejs-getting-started
The relevant pieces:
{
"engines": {
"node": "24.x"
},
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^5.0.0"
}
}
const express = require('express')
const app = express()
const port = process.env.PORT || 3000
app.get('/', (req, res) => res.send('Hello from Express on Primcloud'))
app.listen(port, () => console.log(`listening on ${port}`))
That's the whole app. Commit, push, import into Primcloud, and it deploys.
Procfile for Express on Primcloud#
Express apps can go either way, with or without a Procfile.
Without a Procfile. If package.json has a start script, Primcloud synthesizes web: npm start (or pnpm start / yarn start) for you. This is the shortest path for a standard app.
With a Procfile. Ship one when you want the launch command to be explicit, when you're running additional formations (workers, schedulers), or when you want to bypass npm start and invoke Node directly:
web: node index.js
Direct node invocation shaves a small amount of startup time and one process off the tree: the top-level node process becomes your server instead of a child of npm. Signal handling (SIGTERM at pod shutdown) is also cleaner because your app receives the signal directly.
With background workers:
web: node index.js
worker: node worker.js
Each formation is scaled independently; see Pod Formations.
Env vars this framework expects#
Express itself reads very few env vars. Most of what your app needs is app-specific. The conventional set:
NODE_ENV=production: Express uses this to enable production optimizations (view caching, less verbose error messages). Set it in the Secrets panel on the dashboard.PORT: set by Primcloud on thewebformation (the constant5000). Don't override it; read it and bind to it.DATABASE_URL,REDIS_URL, and other service connection strings: application concerns, not Express concerns, but universally used.
Anything under req.app.locals or process.env at runtime is layered on top of these. Set your own values in the Secrets panel.
Framework gotchas on Primcloud#
Bind to $PORT, not a hardcoded port.
Every Express tutorial hardcodes 3000. On Primcloud, the web formation listens on the port in $PORT (the constant 5000), and only that port is reachable from outside the pod. Read the value from the environment rather than hardcoding it, so the same code runs locally and on Primcloud:
const port = process.env.PORT || 3000
app.listen(port)
Warning
Bind to 0.0.0.0 or omit the host argument entirely (app.listen(port, () => ...)). Never 127.0.0.1: that's not reachable from outside the pod.
Trust the proxy.
Traffic reaches your Express app through Primcloud's ingress layer, so req.ip and req.protocol will reflect the internal proxy hop unless you tell Express to trust the forwarding headers:
app.set('trust proxy', true)
With this on, req.ip returns the visitor's IP from X-Forwarded-For, and req.protocol returns https.
Static assets.
express.static() works out of the box. For any non-trivial traffic pattern, put a CDN in front of Primcloud and serve static assets from there. Node.js is not the fastest static file server, and every static request that hits your app also occupies an event-loop tick.
Body parsing and file uploads.
Express 4.16+ ships express.json() and express.urlencoded(). Use those over the deprecated body-parser package. For multipart uploads, use multer or a streaming parser; buffering large uploads into memory can use more memory than the compute size has.
Graceful shutdown.
When Primcloud rolls a deploy or restarts a pod, the pod receives SIGTERM followed by SIGKILL after a grace period. To shut down cleanly (finish in-flight requests, close database connections, drain queues), wire a SIGTERM handler:
const server = app.listen(port)
process.on('SIGTERM', () => {
console.log('SIGTERM received, closing HTTP server')
server.close(() => {
console.log('HTTP server closed')
process.exit(0)
})
})
Without this, in-flight requests are cut off when the process exits.
Session storage.
The default express-session MemoryStore is per-pod: sessions vanish on restart and don't share across scaled-out pods. For anything past a demo, use a backing store (connect-redis, connect-pg-simple, and so on) so sessions survive pod restarts and scale horizontally.
Troubleshooting#
Deployment succeeds but the URL returns a connection error: the app didn't bind to $PORT. Check the runtime log for the "listening on ..." line; if the port isn't $PORT, fix the app.listen() call.
req.ip is an internal address: you didn't enable app.set('trust proxy', true). Turn it on to unwrap the forwarding header.
Uploads over ~10MB fail with 413 or hang: Express's default body-parser limits are 100kb for JSON and urlencoded. Raise them with express.json({ limit: '10mb' }), or better, stream uploads directly to storage instead of buffering them through Node.js.
Sessions disappear after a redeploy: you're using MemoryStore. Switch to a backing store (connect-redis is the most common), point it at a managed Redis, and sessions survive restarts and scaling.
Cannot find module 'express' at runtime: Express is in devDependencies instead of dependencies. Move it. Every runtime import belongs in dependencies. With pnpm or Yarn, the build prunes dev dependencies after the build scripts. With npm, a package in devDependencies stays installed.
Pod restarts drop in-flight requests: you don't have a SIGTERM handler. Add one that closes the HTTP server and waits for existing requests to finish. See the Graceful shutdown snippet above.