Back to Blog
DevOps
16 min read

Node Server Deployment: Beyond npm start

Your Express app works on localhost. Here's how to make it work everywhere else without the midnight panic calls, from someone who's been woken up too many times.

Brett & Tibbe

Let me tell you about the worst 3 AM wake-up call of my career.

It's 2018. I'm working for a startup that just launched their Node.js API. Everything looks perfect in development. The demos work flawlessly. We deploy with confidence using npm start on our production server.

Two weeks later, my phone explodes at 3:17 AM. The server crashed. Again. This is the fourth time this month. The CEO is furious. Customers are complaining. I'm debugging memory leaks in my pajamas.

Every Node tutorial ends with npm start. That's where your real problems begin.

The Brutal Reality of Production Node

Node.js is fantastic for development. It's fast to iterate, easy to debug, and the feedback loop is immediate. But production Node.js is a different beast entirely.

The problems that don't show up on localhost:

  • Memory leaks that only appear after hours of uptime
  • Single-threaded bottlenecks under real load
  • Unhandled rejections that crash the entire process
  • File descriptor limits that nobody mentions in tutorials
  • Process management when things inevitably go wrong

I learned all of these the hard way, one 3 AM call at a time.

PM2: The Process Manager That Actually Manages

After my fourth middle-of-the-night debugging session, I discovered PM2. It's not sexy, but it's the difference between sleeping through the night and being the human equivalent of a pager.

Forget nodemon in production. PM2 is what you actually want:

npm install -g pm2
pm2 start app.js --name "my-api"
pm2 startup  # Creates startup scripts
pm2 save     # Saves current process list

But here's what separates junior developers from senior ones: the ecosystem.config.js file. This is where you configure PM2 properly:

module.exports = {
  apps: [{
    name: 'my-api',
    script: './app.js',
    instances: 'max',              // One per CPU core
    exec_mode: 'cluster',          // Enable clustering
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    },
    error_file: './logs/err.log',
    out_file: './logs/out.log',
    log_file: './logs/combined.log',
    time: true,                    // Timestamp logs
    max_restarts: 10,              # Don't restart infinitely
    min_uptime: '10s',            # Must stay up 10s to count as stable
    max_memory_restart: '1G'      # Restart if memory usage exceeds 1GB
  }]
}

Start with: pm2 start ecosystem.config.js

I wish someone had shown me this configuration on day one. It would have saved me weeks of debugging and countless sleepless nights.

Clustering: Free Performance (With Gotchas)

Node.js is single-threaded. That's both its strength and its weakness. Great for I/O-heavy workloads, terrible for CPU-intensive tasks.

But your server probably has multiple cores. PM2's cluster mode spawns one Node process per CPU core, with automatic load balancing between them.

instances: 'max' uses all available cores. For most applications, this is what you want.

The clustering gotcha nobody warns you about: Shared state breaks everything.

I spent two days debugging why user sessions kept disappearing randomly. Turns out, I was storing session data in memory. Each clustered process has its own memory space. User logs into worker process #1, next request goes to worker process #2, session doesn't exist.

Use Redis for sessions. Use databases for state. Memory is local to each worker process.

Environment Variables: The Right Way

Most developers handle environment variables wrong. They create an .env file, load it with dotenv, and call it a day.

Here's the issue: .env files are meant for development, not production. In production, your environment variables should come from your deployment system.

Create separate env files:

.env.development:

NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
JWT_SECRET=dev-secret-not-secure

.env.production (never commit this):

NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://prod-db.amazonaws.com:5432/myapp
REDIS_URL=redis://prod-cache.amazonaws.com:6379
JWT_SECRET=actually-secure-random-string

Load them conditionally:

// Only load .env files in development
if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config({ 
    path: `.env.${process.env.NODE_ENV || 'development'}`
  })
}

In production, set environment variables in your deployment system (PM2, Docker, Kubernetes, etc.). Don't rely on files that can be lost or compromised.

Health Checks: Your Early Warning System

Every production server needs health endpoints. Not just "is the process running" but "is the application actually working."

I've seen too many servers where the process was running but the app was completely broken. Database connections timed out, Redis was unreachable, external APIs were down. The server looked fine from the outside.

app.get('/health', (req, res) => {
  res.json({
    status: 'OK',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    memory: {
      used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + 'MB',
      total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024) + 'MB'
    },
    version: process.env.APP_VERSION || 'unknown'
  })
})

But basic health checks aren't enough. You need readiness checks that verify your dependencies:

app.get('/readiness', async (req, res) => {
  const checks = []
  
  try {
    // Check database connection
    await db.raw('SELECT 1')
    checks.push({ service: 'database', status: 'healthy' })
  } catch (error) {
    checks.push({ service: 'database', status: 'unhealthy', error: error.message })
  }
  
  try {
    // Check Redis connection
    await redis.ping()
    checks.push({ service: 'redis', status: 'healthy' })
  } catch (error) {
    checks.push({ service: 'redis', status: 'unhealthy', error: error.message })
  }
  
  const allHealthy = checks.every(check => check.status === 'healthy')
  const statusCode = allHealthy ? 200 : 503
  
  res.status(statusCode).json({
    status: allHealthy ? 'ready' : 'not ready',
    checks
  })
})

Your load balancer should hit the readiness endpoint. If a server instance can't reach its dependencies, traffic should be routed away from it automatically.

Logging: Your Future Self Will Thank You

console.log is not logging. It's debugging. In production, you need structured logging that you can search, filter, and alert on.

Use a real logging library. I prefer Winston, but Pino is faster if performance matters:

const winston = require('winston')

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { 
    service: 'my-api',
    version: process.env.APP_VERSION 
  },
  transports: [
    new winston.transports.File({ 
      filename: 'logs/error.log', 
      level: 'error' 
    }),
    new winston.transports.File({ 
      filename: 'logs/combined.log' 
    })
  ]
})

// In development, also log to console with pretty colors
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.combine(
      winston.format.colorize(),
      winston.format.simple()
    )
  }))
}

Log the right things:

  • Errors with full stack traces - You'll need them for debugging
  • Request/response info - But not sensitive data
  • Performance metrics - Response times, database query times
  • Business events - User registrations, purchases, important actions

Don't log everything. I've seen systems that logged so much they couldn't find actual problems in the noise.

Memory Leaks: The Silent Application Killer

Node applications don't usually die from traffic spikes. They die from memory leaks.

JavaScript has garbage collection, but it's not magic. If you keep references to objects you don't need, they never get collected. Over time, memory usage grows until the process crashes.

The most common causes I've seen:

Event listeners that never get removed:

// Bad - creates a new listener on every request
app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id)
  user.on('update', handleUserUpdate)  // Never removed!
  res.json(user)
})

Timers that keep running:

// Bad - interval keeps running even after request ends
app.get('/status', (req, res) => {
  const interval = setInterval(() => {
    checkStatus()  // Runs forever!
  }, 1000)
  res.json({ status: 'ok' })
})

Closures that capture large objects:

// Bad - callback captures entire request object
app.post('/upload', (req, res) => {
  const largeFile = req.file  // Could be gigabytes
  
  processLater(() => {
    // This closure keeps largeFile in memory forever
    console.log('Processing complete')
  })
  
  res.json({ status: 'uploaded' })
})

Monitor memory usage in your health endpoint and set up alerts. I use PM2's max_memory_restart as a circuit breaker - if memory usage exceeds a threshold, restart the process.

The Docker Way (When You're Ready)

If you're containerizing your Node app (and you probably should), here's a production-ready Dockerfile:

FROM node:18-alpine as builder

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:18-alpine

RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

WORKDIR /app

# Copy built dependencies from builder stage  
COPY --from=builder /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . .

USER nodejs

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

CMD ["node", "server.js"]

Multi-stage builds keep your final image small. Running as a non-root user prevents privilege escalation attacks. Health checks ensure the container orchestrator knows when your app is actually working.

What Breaks in Production (A War Story Collection)

After 8 years of running Node.js in production, here are the issues that will bite you:

File uploads fill up disk space. Always set size limits with multer or similar middleware. I've seen servers crash because someone uploaded a 10GB video file.

Database connections timeout. Use connection pooling (with Knex.js or similar) and implement retry logic. Databases go down. Networks have hiccups. Your app should handle this gracefully.

CORS breaks your frontend randomly. Configure CORS properly from day one. Don't use origin: '*' in production. Be specific about allowed origins.

SSL certificates expire. Use Let's Encrypt with automatic renewal. Set up monitoring to alert you before certificates expire.

Dependencies have security vulnerabilities. Run npm audit regularly. Use tools like Snyk to monitor your dependencies. I've seen companies get breached through outdated npm packages.

Time zones will ruin your day. Always store times in UTC. Always. Display local times in the frontend, but store UTC in the database.

JSON parsing can crash your server. If you accept JSON from users, validate it. Malformed JSON throws exceptions that can crash your entire process.

Monitoring That Actually Matters

Don't just monitor that your process is running. Monitor that your application is healthy:

  • Response times: 95th percentile, not just averages
  • Error rates: Both HTTP errors and application exceptions
  • Memory usage: Growing memory usage indicates leaks
  • CPU usage: Consistent high CPU indicates performance problems
  • Active connections: To databases, Redis, external APIs
  • Queue lengths: If you're using job queues

I use a combination of PM2's built-in monitoring, custom health endpoints, and external services like New Relic or DataDog for deep application monitoring.

The Deployment Pipeline

Here's a production deployment workflow that has served me well:

  1. Code pushed to git triggers CI pipeline
  2. Tests run in container identical to production
  3. Build artifacts created (Docker images or zip files)
  4. Deploy to staging environment for final testing
  5. Blue-green deployment to production with automatic rollback
  6. Health checks verify new deployment is working
  7. Old version shut down after successful deployment

Never deploy directly to production. Always have a staging environment that matches production exactly. I've prevented countless production issues by catching problems in staging first.

My Node.js Production Philosophy

After years of 3 AM phone calls and production firefights, here's what I believe:

Embrace the restart. Node processes will crash. Design your system to handle restarts gracefully. Use PM2 or similar process managers. Don't try to prevent all crashes - handle them elegantly.

State belongs in databases. Don't store important state in memory. Use Redis for sessions, databases for business data. Memory is temporary.

Monitor everything. You can't fix what you can't see. Instrument your applications thoroughly. Alert on trends, not just absolute values.

Test the failure cases. Kill your database mid-request. Fill up your disk. Overload your server with traffic. See how your application behaves under stress.

Keep it simple. Complex deployment procedures fail in complex ways. Automate everything, but keep the automation simple and testable.

Was It Worth It?

Despite all the production war stories, I still love Node.js. The development experience is fantastic. The ecosystem is rich. Performance is excellent for most use cases.

But I wish someone had taught me production best practices earlier. Too many Node.js tutorials focus on getting started quickly and never cover what happens when real users start hitting your application.

The difference between a Node.js app that works on localhost and one that runs reliably in production is enormous. Don't learn this the hard way like I did.

Your future self (and your sleep schedule) will thank you for doing it right from the beginning.

Tired of 3 AM production emergencies?

We help companies build Node.js applications that actually stay running. Proper process management, monitoring, and deployment pipelines that let you sleep at night.

Build Bulletproof Node Apps
Been woken up by crashed Node servers? Share your horror stories.
Read More Posts