Deployment & Best Practices
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.
Development vs. Production
Understanding the difference is crucial:
| Aspect | Development | Production |
|---|
| Error messages | Detailed stack traces | Generic user-friendly messages |
| Performance | Not optimized | Maximized, gzip compression |
| Security | Relaxed (localhost) | Hardened, HTTPS required |
| Logging | Console output | Structured logs, monitoring |
| Restarts | Manual | Automatic recovery on crash |
| Configuration | Hardcoded values OK | Environment variables only |
Why Production Best Practices Matter
Real-World Failures
Ignoring production best practices leads to:
- Security breaches: Hardcoded API keys leaked in public repositories
- Downtime: Single process crashes take down entire application
- Data loss: No proper error handling or logging
- Poor performance: Uncompressed responses, inefficient code
A single exposed API key or database password can compromise your entire application and user data. Never commit secrets to version control.
Let’s go through essential practices to make your application production-ready.
Environment Variables
Never hardcode sensitive information (API keys, database passwords, ports) in your code. Use environment variables.
-
Install
dotenv:
-
Create a
.env file:
PORT=5000
DB_URI=mongodb://localhost:27017/myapp
SECRET_KEY=mysecretkey
-
Use it in your code:
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.
Production Best Practices
-
Use Gzip Compression: Use the
compression middleware to decrease the size of the response body.
const compression = require('compression');
app.use(compression());
-
Security Headers: Use
helmet to set various HTTP headers for security.
const helmet = require('helmet');
app.use(helmet());
-
Logging: Use a logger like
morgan or winston instead of console.log.
-
Error Handling: Implement a global error handling middleware.
-
Clustering: Use Node.js Cluster module or a process manager like PM2 to take advantage of multi-core systems.
Deployment Options
1. Heroku (PaaS)
Heroku is a popular platform for deploying Node.js apps.
- Create a
Procfile in the root:
- Install Heroku CLI and login.
heroku create
git push heroku main
2. Vercel / Netlify (Serverless)
Great for static sites and serverless functions. Next.js apps deploy seamlessly on Vercel.
3. VPS (DigitalOcean, AWS EC2, Linode)
For full control, you can rent a Virtual Private Server.
- Set up Linux (Ubuntu).
- Install Node.js and Nginx (as a reverse proxy).
- Use PM2 to keep your app running.
Using PM2
PM2 is a production process manager for Node.js.
npm install pm2 -g
pm2 start server.js
Commands:
pm2 list: List running processes.
pm2 stop <id>: Stop a process.
pm2 restart <id>: Restart a process.
pm2 logs: View logs.
Summary
- Use Environment Variables for configuration
- Follow security best practices (Helmet, Compression)
- Use a process manager like PM2 for VPS deployments
- Platforms like Heroku and Vercel offer easy deployment
Docker Deployment
Basic Dockerfile
# Dockerfile
FROM node:20-alpine
# Create app directory
WORKDIR /usr/src/app
# Copy package files
COPY package*.json ./
# Install dependencies (production only)
RUN npm ci --only=production
# Copy source code
COPY . .
# Expose port
EXPOSE 3000
# Start the app
CMD ["node", "server.js"]
Optimized Multi-Stage Build
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # If you have a build step
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodeapp -u 1001
# Copy only production dependencies
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy built app
COPY --from=builder /app/dist ./dist
# Switch to non-root user
USER nodeapp
EXPOSE 3000
CMD ["node", "dist/server.js"]
Docker Compose for Development
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=mongodb://mongo:27017/myapp
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
depends_on:
- mongo
- redis
mongo:
image: mongo:6
volumes:
- mongo-data:/data/db
ports:
- "27017:27017"
redis:
image: redis:alpine
ports:
- "6379:6379"
volumes:
mongo-data:
# Run with Docker Compose
docker-compose up -d
docker-compose logs -f app
docker-compose down
AWS Deployment Options
| Service | Best For | Complexity |
|---|
| Elastic Beanstalk | Quick deployment, managed | Low |
| ECS/Fargate | Docker containers | Medium |
| EC2 | Full control | High |
| Lambda | Serverless, APIs | Low |
| App Runner | Containers, simple | Low |
AWS Elastic Beanstalk
# Install EB CLI
pip install awsebcli
# Initialize and deploy
eb init my-node-app --platform node.js
eb create production
eb deploy
# Set environment variables
eb setenv NODE_ENV=production DATABASE_URL=...
Health Checks
Always implement health endpoints:
// Basic health check
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy' });
});
// Comprehensive health check
app.get('/health/detailed', async (req, res) => {
const health = {
uptime: process.uptime(),
timestamp: Date.now(),
status: 'healthy',
checks: {}
};
// Database check
try {
await mongoose.connection.db.admin().ping();
health.checks.database = { status: 'healthy' };
} catch (error) {
health.status = 'unhealthy';
health.checks.database = { status: 'unhealthy', error: error.message };
}
// Redis check
try {
await redis.ping();
health.checks.redis = { status: 'healthy' };
} catch (error) {
health.status = 'unhealthy';
health.checks.redis = { status: 'unhealthy', error: error.message };
}
// Memory check
const memUsage = process.memoryUsage();
health.checks.memory = {
heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`,
heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`
};
res.status(health.status === 'healthy' ? 200 : 503).json(health);
});
Graceful Shutdown
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'));
CI/CD with GitHub Actions
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
# Add deployment steps
echo "Deploying..."