WittCode💻

Create a Load Balancer with Nginx for Node Servers using Docker Compose

By

Learn how to create a load balancer with Nginx for Node servers using Docker Compose. We will go over what a load balancer is, the Nginx upstream directive, docker volumes, and more.

Table of Contents 📖

What is a Load Balancer?

A load balancer takes incoming requests from clients and distributes them among a group of servers. For example, say wittcode.com consists of multiple backend servers. When a client makes a request, the load balancer will intercept it, forward it to a selected server, the server will handle the request, respond back to the load balancer, and then the load balancer forwards the response back to the appropriate client.

Image

Load balancers are helpful for sites that receive a lot of traffic/requests. For example, a ton of requests might be too much for a single server to handle. To handle this, multiple servers can be deployed. These servers often host the same content and the load balancer distributes the requests among the servers in a way that optimally uses each server, preventing the servers from overloading and making maximum use of each server's capacity.

Environment Variable Setup

To begin, lets set up our environment variables. These will be used by Docker Compose to set the location of our node servers and Nginx load balancer in the Docker network.

SERVER_HOST_1=server-c-1
SERVER_PORT_1=9000

SERVER_HOST_2=server-c-2
SERVER_PORT_2=9001

SERVER_HOST_3=server-c-3
SERVER_PORT_3=9002

NGINX_PORT=9999
NGINX_HOST=nginx-c

Each server here will have the same code, just running at a different location.

Node Project Setup

Now lets write the code that will be present in each of our Node servers. First, lets initialize this project as an npm project using npm init es6 -y.

npm init es6 -y

Now lets install nodemon as a development dependency so we can have live code updates.

npm i nodemon -D

Now lets create our HTTP server. We will do this using the built in HTTP module.

import http from 'http';

const SERVER_HOST = process.env.SERVER_HOST;
const SERVER_PORT = process.env.SERVER_PORT;

async function main() {
  try {
    // Create server and listen for requests
    const server = http.createServer();
    server.on('request', async (req, res) => {
      res.end(JSON.stringify({SERVER_HOST, SERVER_PORT}));
    });
    server.listen(SERVER_PORT, SERVER_HOST);
  } catch (err) {
    console.error('Something went wrong', err);
  }
}

main()
  .then(() => console.log('Server started on ' + SERVER_HOST + ':' + SERVER_PORT + '!'))
  .catch(err => console.error('Something went wrong', err));

Each of these Node servers will be a Docker container with their own host and port. This is because each container will have different environment variables loaded in it. Now lets create a start script for this code inside package.json.

"main": "./src/server.js",
...
"scripts": {
  "start": "nodemon ."
}

Creating the Node Docker Image

Now lets create our Node Docker image. Here we will use Node version 20 as the base image.

FROM node:20-alpine
WORKDIR /server
COPY package*.json .
RUN npm i
CMD [ "npm", "start" ]
  • Use Node version 20 as the base image.
  • Set the working directory to /server. This means any RUN, CMD, ENTRYPOINT, COPY, and ADD commands will be executed in this directory.
  • Copy over package.json and package-lock.json.
  • Install dependencies from npm.
  • Start the Node server.

Note how we don't copy over the source code. This is because we will do this using volumes.

Configuring Nginx

Now lets configure Nginx to be a load balancer. First, we will create an upstream.

upstream backend {
  server ${SERVER_HOST_1}:${SERVER_PORT_1}   weight=5;
  server ${SERVER_HOST_2}:${SERVER_PORT_2};
  server ${SERVER_HOST_3}:${SERVER_PORT_3};
}
  • upstream - defines a cluster of servers.
  • server - Defines the address and other parameters of a server.
  • weight - Requests are distributed to servers using a weighted round-robin balancing method. This makes it so 5 requests will go to server 1 before server 2 is hit.

The upstream directive here is defining a load-balanced group of servers. Each server here is a Node server. The dollar sign and curly brace values will all be replaced with environment variables by Docker. Now lets turn Nginx into a reverse proxy.

server {
  listen ${NGINX_PORT};
  server_name ${NGINX_HOST};
  location / {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_pass http://backend;
    proxy_http_version 1.1;
  }
}
  • proxy_set_header - adds headers to the proxied request.
  • X-Forwarded-For - a request header that identifies the originating IP address of a client connecting to a web server through a proxy. $proxy_add_x_forwarded_for is a variable handled by Nginx that adds the client address.
  • Host - specifies the host and port number of the server the request is being sent to.
  • Upgrade - used to upgrade an already establish connection to a different protocol such as HTTP to WebSocket. It is supplied from the client so if not provided it will not be added to the request.
  • Connection - determines whether the network connection stays open after the current transaction ends.
  • proxy_pass - tells Nginx where to forward the request. We are telling it to send the request to our Node server cluster.

Creating the Nginx Docker Image

Now lets create the Dockerfile that will be used to build our Nginx image. All we will do in this Dockerfile is set the base image.

FROM nginx:1.18.0-alpine

We will copy over our configuration into the container using volumes.

Creating a Volume for node_modules

Now lets start working with Docker Compose. To begin, we will create a volume for our node_modules folder. The node_modules folder can be problematic for Docker if it contains packages with binaries specific to certain operating systems. In other words, certain packages will install different files depending on the operating system of the computer. This can cause issues if you are devloping an application with Docker as the Docker container doesn't always use the same OS as the host computer. This is why we create a volume called server-v-node-modules to handle our node_modules.

volumes:
  server-v-node-modules:
    name: "server-v-node-modules"

The top-level volumes declaration lets us configure named volumes. The name property sets a custom name for the volume. Running the command docker compose up for the first time will create the volume. The volume will then be reused when the command is ran subsequently.

Creating our Node Servers with Docker Compose

Now lets create all 3 of our Node servers using Docker Compose. Each configuration will be nearly identical.

version: "3.8"
services:

  server1:
    image: server1
    build:
      context: ./server
      dockerfile: Dockerfile
    container_name: ${SERVER_HOST_1}
    restart: always
    ports:
      - ${SERVER_PORT_1}:${SERVER_PORT_1}
    environment:
      SERVER_HOST: ${SERVER_HOST_1}
      SERVER_PORT: ${SERVER_PORT_1}
    volumes:
      - ./server:/server
      - server-v-node-modules:/server/node_modules

  server2:
    image: server2
    build:
      context: ./server
      dockerfile: Dockerfile
    container_name: ${SERVER_HOST_2}
    restart: always
    environment:
      SERVER_HOST: ${SERVER_HOST_2}
      SERVER_PORT: ${SERVER_PORT_2}
    ports:
      - ${SERVER_PORT_2}:${SERVER_PORT_2}
    volumes:
      - ./server:/server
      - server-v-node-modules:/server/node_modules

  server3:
    image: server3
    build:
      context: ./server
      dockerfile: Dockerfile
    container_name: ${SERVER_HOST_3}
    restart: always
    environment:
      SERVER_HOST: ${SERVER_HOST_3}
      SERVER_PORT: ${SERVER_PORT_3}
    ports:
      - ${SERVER_PORT_3}:${SERVER_PORT_3}
    volumes:
      - ./server:/server
      - server-v-node-modules:/server/node_modules
  • Create 3 services where each is a Node server generated from our Node Dockerfile.
  • Use the environment declaration to pass in the appropriate environment variables for each server.
  • Use a host volume to bring all our server code into the container.

Now all our Node servers will be in their own Docker container and ready to go when we spin them up with Docker Compose.

Creating our Nginx Load Balancer with Docker Compose

Now lets create our Nginx load balancer using Docker Compose.

reverse-proxy:
  image: reverse-proxy
  build: 
    context: ./nginx
    dockerfile: Dockerfile
  container_name: ${NGINX_HOST}
  restart: always
  env_file: .env
  ports:
    - ${NGINX_PORT}:${NGINX_PORT}
  volumes:
    - ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template
  depends_on:
    - server1
    - server2
    - server3
  • Load all environment variables into the Nginx image using the env_file declaration.
  • Use the depends_on declaration to tell Docker Compose to start the Nginx service after all server services have started.
  • Bring in our Nginx configuration using a host volume. Placing it inside the templates directory will allow for Docker to carry out environment variable substitution with envsubst.

Running the Application

To run the application, all we need to do is navigate to the top level directory and run docker compose up.

docker compose --env-file .env up

Note how we also supply our environment variable file to Docker Compose. This is so it loads the environment variables into the docker-compose.yaml file. Checking the console output, we can see all our Node servers are up and running.

server-c-3  | Server started on server-c-3:9002!
server-c-2  | Server started on server-c-2:9001!
server-c-1  | Server started on server-c-1:9000!

Now lets send some requests using cURL. We will do this by creating a simple script to run a cURL command 20 times.

for i in {1..20}
do
  curl localhost:9999
  echo \n
done

Don't forget to make the script file executable by running chmod.

chmod +x test.sh

Now simply run the file and look for the output.

./test.sh
{"SERVER_HOST":"server-c-3","SERVER_PORT":"9002"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-2","SERVER_PORT":"9001"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-3","SERVER_PORT":"9002"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-2","SERVER_PORT":"9001"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-3","SERVER_PORT":"9002"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-1","SERVER_PORT":"9000"}n
{"SERVER_HOST":"server-c-2","SERVER_PORT":"9001"}n

We can see the impact of the weight attribute used in the Nginx upstream directive. Notice how server-c-1 is contacted 5 times more frequently than the other two in a round robin fashion. Remember, these responses are being passed from Nginx to the Node servers. The servers are then appending their locations in a JSON payload and returning it.