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
Improved performance – by sharing the workload.
Increased availability – through redundancy.
Easier horizontal scaling – as demand grows, you can spin up more containers.
Project Overview
This project demonstrates how to:
Containerize an Express.js API using Docker.
Scale the API across multiple containers.
Use Nginx as a reverse proxy and load balancer more containers.
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
Navigate to the project directory:
cd express-js-nginx-lb/app
Install dependencies:
npm install
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 atapp/src/db/express-test-db.sql
Start the development server:
npm run dev
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
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
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;
}
}
}
Implementation Details
Docker commands and snapshots is provided below.
docker compose build
docker compose up
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()}`);
});
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
Here's how to inspect the environment variables inside a running container:
docker exec -it <container_id> /bin/sh
ls
cd /
cd etc/express_env
ls -a
exit
API Response Snapshot
/api/v1/
- root route
/api/v1/create-post
- create post route
/api/v1/post/: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
Monitoring and Observability: Implement comprehensive monitoring for the backend API including metrics collection, centralized logging, and distributed tracing
CI/CD Pipeline: Deploy to cloud infrastructure using automated CI/CD pipelines with GitHub Actions
Security Enhancements: Add SSL/TLS termination at the Nginx layer to secure API communications
Top comments (0)