DEV Community

Cover image for Scaling Express.js with Nginx Load Balancing: A Dockerized Approach
Obaseki Noruwa
Obaseki Noruwa

Posted on

Scaling Express.js with Nginx Load Balancing: A Dockerized Approach

Modern web applications need to be scalable, resilient, and maintainable. One of the most effective ways to achieve this is by using Docker to containerize your application and Nginx as a load balancer. In this post, I will walk you through how I built a scalable Express.js backend API, balanced with Nginx and orchestrated using Docker Compose.

Why Load Balancing Matters

  1. Improved performance – by sharing the workload.

  2. Increased availability – through redundancy.

  3. Easier horizontal scaling – as demand grows, you can spin up more containers.

Project Overview

This project demonstrates how to:

  1. Containerize an Express.js API using Docker.

  2. Scale the API across multiple containers.

  3. Use Nginx as a reverse proxy and load balancer more containers.

  4. Implement round-robin request distribution.

Installation

Before you read pass this point please clone the Express.Js project, Or you can develop your own Express.Js backend API and expose the port in the compose.yml file.

Clone the repository:

   git clone https://github.com/noruwa03/express-js-nginx-lb
Enter fullscreen mode Exit fullscreen mode

Navigate to the project directory:

   cd express-js-nginx-lb/app
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

   npm install
Enter fullscreen mode Exit fullscreen mode

Set up environment variables:
Create a .env file inside the app directory with the following variables:

  • PORT - Port number for the Express.js server
  • DB_CONN_LINK - PostgreSQL connection string from Neon DB. To create Database and Tables please open the express-test-db.sql file at app/src/db/express-test-db.sql

Start the development server:

   npm run dev
Enter fullscreen mode Exit fullscreen mode

Project Structure

express-js-nginx-lb/
├── app/
│   ├── src/
│   │   ├── controllers/
│   │   │   ├── create-post.ts
│   │   │   ├── delete-post.ts
│   │   │   ├── get-post-by-id.ts
│   │   │   ├── get-posts.ts
│   │   │   └── update-post.ts
│   │   ├── db/
│   │   │   ├── express-test-db.sql
│   │   │   └── index.ts
│   │   ├── middlewares/
│   │   │   └── post-validation.ts
│   │   ├── routes/
│   │   │   └── index.ts
│   │   └── app.ts
│   ├── .dockerignore
│   ├── package-lock.json
│   ├── package.json
│   └── tsconfig.json
├── nginx/
│   └── nginx.conf
├── compose.yml
└── README.md
Enter fullscreen mode Exit fullscreen mode

Key Components

Express.js API

The backend API handles basic CRUD operations for posts, with these endpoints:

  • GET /post - Retrieve all posts.

  • GET /post/:id - Get a specific post.

  • POST /create-post - Create a new post.

  • PATCH/update-post/:id - Update a post.

  • DELETE/post/:id - Delete a post.

Docker Configuration: compose.yml

services:
  myapp:
    build: ./app
    deploy:
      replicas: 3
    expose: 
      - 8080
    volumes:
      - ./app/.env:/etc/express_env/.env

  nginx:
    image: nginx:alpine
    ports:
      - 3000:80
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - myapp

Enter fullscreen mode Exit fullscreen mode

If you are developing your own Express.Js backend API, remember to change the port in the expose list

Nginx Configuration: nginx.conf located at nginx directory

events {}

http {
  include mime.types;

  upstream myapp {
    server myapp:8080;
  }

  server {
    listen 80;
    location / {
      proxy_pass http://myapp;
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementation Details

Docker commands and snapshots is provided below.

docker compose build
Enter fullscreen mode Exit fullscreen mode

Docker Compose Build

docker compose up
Enter fullscreen mode Exit fullscreen mode

Docker Compose Up

docker ps
Enter fullscreen mode Exit fullscreen mode

Docker PS

Container Identification

To verify load balancing was working, I added a /ping endpoint that returns the hostname of the container serving the request:

import os from "os"

app.get("/ping", (_: Request, res: Response): any => {
  return res.status(200).json({
    message: `Served by: ${os.hostname()}`,
  });
});

app.listen(PORT, () => {
  console.log(`Server running at port ${PORT}.....`);
  console.log(`Served by: ${os.hostname()}`);
});

Enter fullscreen mode Exit fullscreen mode

Container One

Container two

Environment Variable Management

I wanted to securely handle the .env file using secrets and map .env file in a volume to the secrets in the Docker Compose configuration. However, when running docker compose -f compose.yml config for the compose.yml file validation, I encountered an error. I didn't want to copy the .env file directly into the Docker container, so I used Docker volumes to map the app/.env file to the /etc/express_env/.env directory inside the container. This approach keeps sensitive data out of the container image while still making it accessible at runtime.

volumes:
  - ./app/.env:/etc/express_env/.env
Enter fullscreen mode Exit fullscreen mode

Here's how to inspect the environment variables inside a running container:

docker exec -it <container_id> /bin/sh
Enter fullscreen mode Exit fullscreen mode
ls
Enter fullscreen mode Exit fullscreen mode
cd /
Enter fullscreen mode Exit fullscreen mode
cd etc/express_env
ls -a
exit
Enter fullscreen mode Exit fullscreen mode

Container Info

API Response Snapshot

/api/v1/ - root route

Root Route

/api/v1/create-post - create post route

Create Post

/api/v1/post/:id - get post by id

Get Post By ID

Challenges and Solutions

Environment Variable Security

Challenge: Initially, I wanted to use Docker's secrets but encountered configuration errors.

Solution: Using volumes provided a secure way to inject environment variables at runtime without baking them into the container image.

Load Balancing Verification

Challenge: Confirming that requests were actually being distributed across containers.

Solution: Implementing the /ping endpoint that returns the container hostname provided clear visual confirmation.

Recommendations for Future Improvements

  1. Monitoring and Observability: Implement comprehensive monitoring for the backend API including metrics collection, centralized logging, and distributed tracing

  2. CI/CD Pipeline: Deploy to cloud infrastructure using automated CI/CD pipelines with GitHub Actions

  3. Security Enhancements: Add SSL/TLS termination at the Nginx layer to secure API communications

Top comments (0)