The gap between development mode and production mode is wider than most people assume. In development, you care about seeing results fast, and if the server crashes you just restart it. In production, you care about stability, security, performance, and — most importantly — the app holding itself together at 3 a.m. when nobody's watching.
Express doesn't solve these problems for you. It's a minimal framework that gives you routing and middleware, and that's it. Production reliability, security, and performance are things you build on top through configuration and third-party middleware.
7.1 Environment Configuration: NODE_ENV and What It Controls
NODE_ENV is a simple string environment variable, but it controls more than most people realize. Express uses it internally to decide a range of behaviors:
- View caching: In production mode, Express caches compiled templates. In development, it recompiles on every request.
- Error verbosity: Production error responses contain only the status message. Development responses include full stack traces.
- CSS extension caching: If you're using a CSS preprocessor, caching is enabled in production.
Set the variable when starting the app:
NODE_ENV=production node app.js
Or hardcode it in your package.json scripts:
{
"scripts": {
"start": "NODE_ENV=production node app.js",
"dev": "NODE_ENV=development node app.js"
}
}
Don't use app.set('env', 'production') in code. That forces the app into production mode regardless of the actual environment, and you lose the ability to be environment-aware. The correct approach is always to rely on process.env.NODE_ENV.
Beyond NODE_ENV, production environments need to manage other sensitive configuration: database connection strings, JWT secrets, third-party API keys. These should not be hardcoded in source. A common pattern is using dotenv to load a .env file, but production secrets should come from a more secure source — systemd's Environment directive, or your container orchestrator's secret management.
7.2 Process Management: Keeping the App Alive
In development, you start the server with node app.js. Doing that in production is disastrous — if the app crashes on an uncaught exception, it stays down until someone manually restarts it.
You need a process manager. Its job is to restart the app when it crashes, start it on server boot, and provide runtime resource monitoring.
Historically, PM2 was the most popular choice in the Node.js ecosystem:
npm install -g pm2
pm2 start app.js --name "my-app"
pm2 startup # Configure boot startup
pm2 save
PM2's cluster mode can also take advantage of multi-core CPUs:
pm2 start app.js -i 4 # Start 4 instances
But Express's official documentation now recommends prioritizing the operating system's built-in init system, like Linux's systemd. The reasoning: systemd is lower-level, more reliable, and doesn't introduce an additional Node.js dependency.
A systemd service file looks like this:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Express App
After=network.target
[Service]
Environment=NODE_ENV=production
Environment=PORT=8080
ExecStart=/usr/bin/node /var/www/myapp/app.js
Restart=always
User=www-data
[Install]
WantedBy=multi-user.target
The Restart=always line is the lifeline. App crashed? systemd brings it back. Server rebooted? systemd brings it back.
7.3 Security Hardening: From Exposed to Defended
Express's default security posture is essentially zero. It doesn't send security headers, doesn't rate-limit requests, doesn't validate input, and returns full stack traces on errors. Those defaults are convenient in development and dangerous in production.
Helmet: One Line, a Stack of Security Headers
helmet is the starting point for Express security hardening. It sets a series of HTTP response headers that browsers use to enforce protections.
const helmet = require('helmet');
app.use(helmet());
The headers it enables by default include:
| Header | Purpose |
|---|---|
Content-Security-Policy | Mitigates XSS and data injection |
Strict-Transport-Security | Enforces HTTPS |
X-Content-Type-Options | Prevents MIME sniffing |
X-Frame-Options | Mitigates clickjacking |
CSP is the part of Helmet worth spending time on. The default policy is fairly permissive. Production should tighten it based on where your assets actually come from:
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
}
}));
Rate Limiting: Stopping Brute Force and DoS
Express doesn't limit request frequency by default. Without rate limiting, your login endpoint can be attempted indefinitely, and your API can be brought down by a single script.
express-rate-limit is the standard choice:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
limit: 100, // Max 100 requests per IP
standardHeaders: 'draft-7',
});
app.use(limiter);
Critical detail: the default in-memory store does not share state across processes. If you're running 4 instances in PM2 cluster mode, each instance counts separately, and the effective limit becomes 400 instead of 100. Production must use a Redis store.
Another trap: req.ip returns the proxy's IP when you're behind a proxy, not the real client IP. That means every request shares the same rate-limit bucket. You must configure trust proxy correctly:
app.set('trust proxy', 1); // Trust one layer of proxy (your load balancer)
Don't use true. That trusts all proxy headers, and clients can forge X-Forwarded-For to bypass the limit.
CORS: It's Not Just cors() and Done
Production CORS must specify allowed origins explicitly, not origin: '*':
const cors = require('cors');
app.use(cors({
origin: process.env.NODE_ENV === 'production'
? '<https://yourdomain.com>'
: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
Input Validation: Never Trust req.body
Every property in req.body, every value in req.query, every field in req.params comes from the user and is untrusted. The Express documentation explicitly warns that req.body.foo.toString() can fail in multiple ways — foo might not exist, might not be a string, and toString might not be a function.
Validation should happen at the boundary, using a schema to define the shape of legal input:
const { z } = require('zod');
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().trim().max(100),
});
app.post('/users', (req, res) => {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
// result.data is now validated input
});
Session and Cookie Hardening
If you're using sessions, the cookie flags are non-negotiable:
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Not readable from JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF mitigation
maxAge: 3600000, // 1 hour
}
}));
httpOnly prevents XSS attacks from stealing the session token. sameSite blocks CSRF at the browser level.
Note: the csurf package is deprecated — don't use it. If you need CSRF protection, consider csrf-csrf or switch to pure token-based authentication.
7.4 Logging: Knowing What Happened When Things Break
Production can't rely on console.log. Console output disappears when the process restarts, has no structure, can't be searched, and doesn't distinguish severity.
Winston + Morgan: Application Logs and Request Logs Separated
morgan logs HTTP requests (who accessed what, when). winston logs application events (database errors, business logic exceptions).
const winston = require('winston');
const morgan = require('morgan');
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
new winston.transports.Console(),
],
});
app.use(morgan('combined', { stream: { write: msg => logger.info(msg.trim()) } }));
Error Logs Need Context
Logging err.stack alone isn't enough. You also need to know: which request triggered it, from what IP, what the request path was, and who the user was.
app.use((err, req, res, next) => {
logger.error({
message: err.message,
stack: err.stack,
method: req.method,
path: req.path,
ip: req.ip,
});
res.status(500).send('Internal Server Error');
});
7.5 Performance: Making Responses Faster
Compress Responses
The compression middleware gzips response bodies, significantly reducing transfer size:
const compression = require('compression');
app.use(compression());
Cache Static Assets
The maxAge option on express.static lets browsers cache static files and reduces repeat requests:
app.use(express.static('public', {
maxAge: '1d',
etag: true,
}));
Avoid Synchronous Code
Synchronous operations block the event loop. In Express, that means every concurrent request is stuck until the sync operation finishes:
// Wrong
app.get('/file', (req, res) => {
const data = fs.readFileSync('./large-file.txt', 'utf8');
res.send(data);
});
// Right
app.get('/file', (req, res, next) => {
fs.readFile('./large-file.txt', 'utf8', (err, data) => {
if (err) return next(err);
res.send(data);
});
});
7.6 Error Handling: Production Must Not Leak Stacks
In development, error stacks are useful debugging information. In production, they're intelligence for attackers — file paths, dependency versions, and internal logic structure all exposed in the stack.
The error handler should switch behavior based on environment:
app.use((err, req, res, next) => {
logger.error(err);
if (process.env.NODE_ENV === 'development') {
res.status(err.status || 500).json({
message: err.message,
stack: err.stack,
});
} else {
res.status(err.status || 500).send('Internal Server Error');
}
});
Error handlers must be defined after all routes and middleware. If defined earlier, they can't catch errors produced by later code.