You’ve built your Node.js application, and it works perfectly on your machine. But running in production is a completely different challenge. Production environments face real-world constraints: thousands of concurrent users, security threats, server crashes, and the need for zero-downtime updates.The gap between “it works on my machine” and “it works in production” is where most Node.js projects fail. A senior engineer would tell you that writing the application is only about 30% of the work—the other 70% is making it reliable, observable, secure, and deployable. This chapter covers that 70%.
// Load .env file FIRST, before any other imports that might need env vars.// This must be the very first line in your entry file.require('dotenv').config();const PORT = process.env.PORT || 3000;console.log(process.env.DB_URI);
Important: Add .env to your .gitignore file so it’s not committed to version control.
PM2 is a production process manager for Node.js. It solves two critical problems: (1) your Node.js process crashes and nobody restarts it, leaving users with a dead server, and (2) your single-threaded Node process only uses one CPU core on a multi-core machine. PM2 handles automatic restarts on crash and can run your app in cluster mode across all available cores.
npm install pm2 -gpm2 start server.js# Cluster mode -- run one process per CPU core for maximum throughputpm2 start server.js -i max# Generate a startup script so PM2 restarts your app after server rebootpm2 startuppm2 save
# DockerfileFROM node:20-alpine# Create app directoryWORKDIR /usr/src/app# Copy package files FIRST -- this layer is cached as long as dependencies# do not change, so rebuilds after code-only changes are much faster.COPY package*.json ./# Use npm ci (not npm install) for reproducible builds from lockfile.# --only=production skips devDependencies, reducing image size.RUN npm ci --only=production# Copy source code AFTER installing deps to maximize Docker layer cachingCOPY . .# EXPOSE documents the port but does not publish it -- you still need# -p 3000:3000 when running the containerEXPOSE 3000# Use array syntax (exec form) so Node.js receives SIGTERM directly# for graceful shutdown. String form runs via /bin/sh which swallows signals.CMD ["node", "server.js"]
A graceful shutdown means your server stops accepting new connections but finishes processing all in-flight requests before exiting. Without graceful shutdown, deploying a new version kills active requests mid-response—users see errors, database transactions get interrupted, and files get partially written. Every production Node.js server should implement this pattern.
const server = app.listen(PORT);const gracefulShutdown = async (signal) => { console.log(`Received ${signal}. Starting graceful shutdown...`); // Stop accepting new connections server.close(async () => { console.log('HTTP server closed'); try { // Close database connections await mongoose.connection.close(); console.log('MongoDB connection closed'); // Close Redis await redis.quit(); console.log('Redis connection closed'); console.log('Graceful shutdown completed'); process.exit(0); } catch (error) { console.error('Error during shutdown:', error); process.exit(1); } }); // Force shutdown after 30 seconds setTimeout(() => { console.error('Forced shutdown after timeout'); process.exit(1); }, 30000);};process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));process.on('SIGINT', () => gracefulShutdown('SIGINT'));