Youssef Ameachaq's Blog

Youssef Ameachaq

Running a Node.js Backend in Production


Running a Node.js backend project in production involves ensuring the application is reliable, scalable, secure, and maintainable. Below, I’ll explain the key considerations and best practices, along with an examples to make it clear.

Key Considerations for Running a Node.js Backend in Production

  1. Project Structure and Code Organization

    • A clean project structure makes maintenance easier.

    • Use modular code to separate concerns (e.g., routes, controllers, services, models).

    • Example:

      // project structure
      /src
        /controllers/userController.js
        /routes/userRoutes.js
        /services/userService.js
        /models/userModel.js
        /config/db.js
        /middleware/auth.js
        app.js
      • Why? Keeps code organized, testable, and scalable.
      • Tip: Use environment variables for configuration (e.g., database URLs, API keys).
      // .env
      PORT=3000
      DB_URL=mongodb://localhost:27017/myapp
      JWT_SECRET=mysecretkey
      
      // config/db.js
      require('dotenv').config();
      const mongoose = require('mongoose');
      
      const connectDB = async () => {
        try {
          await mongoose.connect(process.env.DB_URL, { useNewUrlParser: true });
          console.log('Database connected');
        } catch (error) {
          console.error('Database connection error:', error);
          process.exit(1); // Exit on failure
        }
      };
      
      module.exports = connectDB;
  2. Use a Process Manager

    • Node.js runs in a single thread, so use a process manager like PM2 to handle crashes, restarts, and scaling.
    • Why? PM2 ensures your app stays up, manages multiple instances, and provides monitoring.
    • Example:
      # Install PM2 globally
      npm install -g pm2
      
      # Start your app
      pm2 start app.js --name myapp --watch
      
      # Enable cluster mode for multiple CPU cores
      pm2 start app.js -i max
      
      # Monitor logs
      pm2 logs
      
      # Restart on crash
      pm2 restart myapp
  3. Handle Errors Gracefully

    • Unhandled errors can crash your app. Use try-catch and global error handlers.
    • Example:
      // app.js
      const express = require('express');
      const app = express();
      
      // Global error handler
      app.use((err, req, res, next) => {
        console.error(err.stack);
        res.status(500).json({ message: 'Something went wrong!' });
      });
      
      // Example route with error handling
      app.get('/user/:id', async (req, res, next) => {
        try {
          const user = await User.findById(req.params.id);
          if (!user) throw new Error('User not found');
          res.json(user);
        } catch (error) {
          next(error); // Pass to global error handler
        }
      });
  4. Optimize Performance

    • Use clustering to utilize multiple CPU cores.
    • Cache frequently accessed data (e.g., with Redis).
    • Example (Clustering):
      const cluster = require('cluster');
      const numCPUs = require('os').cpus().length;
      
      if (cluster.isMaster) {
        console.log(`Master ${process.pid} is running`);
      
        // Fork workers
        for (let i = 0; i < numCPUs; i++) {
          cluster.fork();
        }
      
        cluster.on('exit', (worker, code, signal) => {
          console.log(`Worker ${worker.process.pid} died`);
          cluster.fork(); // Restart worker
        });
      } else {
        const express = require('express');
        const app = express();
        app.get('/', (req, res) => res.send('Hello World!'));
        app.listen(3000, () => console.log(`Worker ${process.pid} started`));
      }
    • Redis Cache Example:
      const redis = require('redis');
      const client = redis.createClient({ url: process.env.REDIS_URL });
      
      client.on('error', (err) => console.log('Redis error:', err));
      
      // Cache middleware
      const cache = async (req, res, next) => {
        const key = `user:${req.params.id}`;
        const cachedData = await client.get(key);
        if (cachedData) {
          return res.json(JSON.parse(cachedData));
        }
        next();
      };
      
      app.get('/user/:id', cache, async (req, res) => {
        const user = await User.findById(req.params.id);
        await client.setEx(`user:${req.params.id}`, 3600, JSON.stringify(user)); // Cache for 1 hour
        res.json(user);
      });
  5. Security Best Practices

    • Use HTTPS to encrypt traffic.
    • Validate and sanitize user inputs to prevent injection attacks.
    • Use libraries like helmet for HTTP security headers and express-rate-limit for rate limiting.
    • Example:
      const helmet = require('helmet');
      const rateLimit = require('express-rate-limit');
      
      app.use(helmet()); // Add security headers
      
      // Rate limit middleware
      const limiter = rateLimit({
        windowMs: 15 * 60 * 1000, // 15 minutes
        max: 100, // Limit to 100 requests per IP
      });
      app.use(limiter);
      
      // Input validation with express-validator
      const { body, validationResult } = require('express-validator');
      
      app.post(
        '/register',
        [
          body('email').isEmail().normalizeEmail(),
          body('password').isLength({ min: 6 }),
        ],
        (req, res, next) => {
          const errors = validationResult(req);
          if (!errors.isEmpty()) {
            return res.status(400).json({ errors: errors.array() });
          }
          // Proceed with registration
          res.json({ message: 'User registered' });
        }
      );
  6. Logging and Monitoring

    • Use logging to track errors and performance (e.g., Winston or Morgan).
    • Monitor app health with tools like New Relic or Prometheus.
    • Example (Winston Logging):
      const winston = require('winston');
      
      const logger = winston.createLogger({
        level: 'info',
        format: winston.format.combine(
          winston.format.timestamp(),
          winston.format.json()
        ),
        transports: [
          new winston.transports.File({ filename: 'error.log', level: 'error' }),
          new winston.transports.File({ filename: 'combined.log' }),
        ],
      });
      
      if (process.env.NODE_ENV !== 'production') {
        logger.add(new winston.transports.Console({ format: winston.format.simple() }));
      }
      
      // Usage
      app.get('/test', (req, res) => {
        logger.info('Test endpoint called');
        try {
          throw new Error('Test error');
        } catch (error) {
          logger.error('Error in /test:', error);
          res.status(500).send('Error');
        }
      });
  7. Database Management

    • Use connection pooling for databases like MongoDB or PostgreSQL.
    • Handle migrations for schema changes.
    • Example (MongoDB Connection Pooling):
      // config/db.js (already shown above)
      const mongoose = require('mongoose');
      
      mongoose.connection.on('connected', () => console.log('MongoDB connected'));
      mongoose.connection.on('disconnected', () => console.log('MongoDB disconnected'));
      mongoose.connection.on('error', (err) => console.log('MongoDB error:', err));
  8. Deployment and Scaling

    • Deploy on a cloud platform like AWS, GCP, or Heroku.
    • Use Docker for containerization and Kubernetes for orchestration.
    • Example (Dockerfile):
      FROM node:18
      WORKDIR /app
      COPY package*.json ./
      RUN npm install
      COPY . .
      EXPOSE 3000
      CMD ["npm", "start"]
    • Scaling: Use a load balancer (e.g., AWS ELB) and auto-scaling groups.
    • CI/CD: Set up pipelines with GitHub Actions or Jenkins for automated testing and deployment.
      # .github/workflows/deploy.yml
      name: Deploy to Production
      on:
        push:
          branches: [main]
      jobs:
        deploy:
          runs-on: ubuntu-latest
          steps:
            - uses: actions/checkout@v3
            - uses: actions/setup-node@v3
              with:
                node-version: '18'
            - run: npm install
            - run: npm test
            - run: npm run build
            - name: Deploy to Server
              run: ssh user@server 'cd /app && git pull && npm install && pm2 restart myapp'
  9. Environment Configuration

    • Use NODE_ENV=production to optimize Node.js for production.
    • Example:
      // app.js
      if (process.env.NODE_ENV === 'production') {
        app.set('trust proxy', 1); // For reverse proxies
        // Other production-specific settings
      }
  10. Backup and Recovery

    • Regularly back up your database.
    • Have a disaster recovery plan (e.g., restore from backups, failover to another region).
    • Example (MongoDB Backup Script):
      # backup.sh
      mongodump --uri=$DB_URL --out=/backup/$(date +%F)

If you’re a Technical / Team Lead


Example

// app.js
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const winston = require('winston');
const connectDB = require('./config/db');

const app = express();

// Logger setup
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
  transports: [new winston.transports.File({ filename: 'combined.log' })],
});

// Middleware
app.use(helmet());
app.use(express.json());
app.use(
  rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 100,
  })
);

// Connect to database
connectDB();

// Routes
app.use('/api/users', require('./routes/userRoutes'));

// Global error handler
app.use((err, req, res, next) => {
  logger.error(err.stack);
  res.status(500).json({ message: 'Server error' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => logger.info(`Server running on port ${PORT}`));
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const { getUser } = require('../controllers/userController');

router.get('/:id', getUser);

module.exports = router;
// controllers/userController.js
const User = require('../models/userModel');

exports.getUser = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) throw new Error('User not found');
    res.json(user);
  } catch (error) {
    next(error);
  }
};

Final Tips for the Technical Lead