One of the importance of NGINX is its use as a reverse proxy between user requests and a web server. To explain what a reverse proxy is, think of it like a traffic warden on a busy road. Since we are referring to web requests, a reverse proxy acts as a traffic warden, receiving user requests and sending them to the appropriate destination based on the URL. This process eventually would help to reduce request congestions, simplify routing, and enhance scalability.

This tutorial guides you through creating a multi-container application with Nginx acting as a reverse proxy for the microservices. We’ll use Docker Compose to manage the containers and simplify deployment locally.

Prerequisites:

  • Docker: Download and install Docker Desktop from Docker’s official website.
  • Docker Compose: Install Docker Compose following the instructions on Docker’s documentation.
  • Basic understanding of Python: The assumption is that you are mildly familiar with python and concepts like APIs for creating a simple backend service.

Project Steps:

Step 1. Building a Microservices-Based Task Management Application

In this tutorial, we’ll create two simple backend services for a Task Management Application:

  1. User Service: Handles user login and authentication.
  2. Task Service: Manages task creation, modification, and retrieval.

The code for this project can be found on my Github repo.

We’ll start by creating a folder and two sub-folders in the terminal:

mkdir nginxxdocker
mkdir nginxxdocker/user_service
mkdir nginxxdocker/task_service

Next, activate a virtual environment and install Flask for each service:

pip install Flask

User Service

Create an app.py file inside the user_service directory with the following code:

from flask import Flask, request, jsonify
from functools import wraps

app = Flask(__name__)

if __name__ == '__main__':
    app.run(debug=True)

users = [
    {"username": "user1", "password": "password1", "email": "[email protected]", "role": "user"},
    {"username": "user2", "password": "password2", "email": "[email protected]", "role": "admin"}
]

# defining route
@app.route('/login', methods=['POST'])
def login():
    auth = request.authorization
    if not auth or not auth.username or not auth.password:
        return jsonify({"error": "Authentication failed"}), 401

    user = next((user for user in users if user['username'] == auth.username), None)
    if not user or user['password'] != auth.password:
        return jsonify({"error": "Authentication failed"}), 401
    
    return jsonify({"message": "Login successful", "user": user}), 200

# Authorization for Admin Dashboard
def requires_role(role):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            auth = request.authorization
            if not auth or not auth.username:
                return jsonify({"error": "Unauthorized"}), 401
            
            user = next((user for user in users if user['username'] == auth.username), None)
            if not user or user['role'] != role:
                return jsonify({"error": "Unauthorized"}), 401
            
            return func(*args, **kwargs)
        return wrapper
    return decorator

@app.route('/admin/dashboard')
@requires_role('admin')
def admin_dashboard():
    return jsonify({"message": "Welcome to admin dashboard"}), 200

In this code, we have implemented two endpoint URLs for user authentication - /login and /admin/dashboard. Now let’s go ahead to create the task service.

Task Service

Similarly, create an app.py file inside the task_service directory with the following code:

from flask import Flask, request, jsonify
from datetime import datetime

app = Flask(__name__)

if __name__ == '__main__':
    app.run(debug='True')

class Task:
    def __init__(self, id, title, description, due_date, assignee):

        self.id = id
        self.title = title
        self.description = description
        self.due_date = due_date
        self.assignee = assignee
        self.created_at = datetime.now()

tasks = []

# Defining routes for CRUD tasks
@app.route('/tasks', methods=['GET'])
def get_tasks():
    return jsonify([task.__dict__ for task in tasks])

@app.route('/tasks/<int:task_id>', methods=['GET'])
    def get_task(task_id):
    task = next((task for task in tasks if task.id == task_id), None)
    if task:
        return jsonify(task.__dict__)
    else:
        return jsonify({"error": "Task not found"}), 404

@app.route('/tasks', methods=['POST'])
def create_task():
    data = request.json
    task = Task(id=len(tasks) + 1, title=data['title'], description=data['description'], due_date=data['due_date'], assignee=data['assignee'])
    tasks.append(task)
    return jsonify(task.__dict__), 201

@app.route('/tasks/<int:task_id>', methods=['PUT'])
def update_task(task_id):
    data = request.json
    task = next((task for task in tasks if task.id == task_id), None)
    if task:
        task.title = data.get('title', task.title)
        task.description = data.get('description', task.description)
        task.due_date = data.get('due_date', task.due_date)
        task.assignee = data.get('assignee', task.assignee)
        return jsonify(task.__dict__)
    else:
        return jsonify({"error": "Task not found"}), 404

@app.route('/tasks/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
    global tasks
    tasks = [task for task in tasks if task.id != task_id]
    return '', 204

In the above code, we have implemented a service APIfor managing tasks, with /tasks as the URL endpoint for performing CRUD requests.

Step 2. Testing with Postman:

Now we need to make sure these services can accept requests on Postman.

  • Test User Service: Run the user service with the flask run command locally, and then send a POST request with the username and password in the request’s Authorization header for /login and GET request for the /admin/dashboard route.

POST Request to /login

GET Request to /admin/dashboard

  • Test Task Service: Similarly, test endpoints for creating, reading, updating, and deleting tasks in your task service can be done with the /tasks endpoint.

POST Request to /tasks

GET Request to /tasks/1

Step 3. Dockerizing the Services:

Now that the services are running and tested, let’s containerize them using Docker.

Dockerfile for User Service

Create a file named Dockerfile inside the user_service directory with the following content:

FROM python:3.10

# Set the working directory in the container to /app
WORKDIR /app

COPY . .

# Install Flask and other dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Run the command to start the Flask app
CMD ["flask", "run", "--host", "0.0.0.0"]

Dockerfile for Task Service

Similarly, create a file named Dockerfile inside the task_service directory with the following content:

FROM python:3.10

# Set the working directory in the container to /app
WORKDIR /app

COPY . .

# Install Flask and other dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Run the command to start the Flask app
CMD ["flask", "run", "--host", "0.0.0.0"]

Run the following commands in each service directory to build Docker images:

docker build -t user_service .
docker build -t task_service .

Step 4: Setting Up Nginx Reverse Proxy

Now that we have our Docker images ready, let’s set up Nginx as a reverse proxy to route requests to the appropriate services.

Inside the root directory, nginxxdocker, create a file named nginx.conf with the following code in it:

events{
worker_connections 1024;
}

http {

    server {
        listen 80;
        server_name localhost 127.0.0.1;

        location /login {
            proxy_pass          http://user_service:5000;
            proxy_set_header    X-Forwarded-For $remote_addr;
        }

        location /admin/dashboard {
            proxy_pass          http://user_service:5000;
            proxy_set_header    X-Forwarded-For $remote_addr;
        }

        location /tasks {
            proxy_pass          http://task_service:5000;
            proxy_set_header    X-Forwarded-For $remote_addr;
        }
    }

}

It is important to note that I initially had a 502 Gateway error after running Docker Compose, (something we’ll do in the next step). What I had to do was to add the port 5000 to the each of the proxy_pass directives so NGINX can forward any requests made to that port. Without specifying the port, Nginx will attempt to use the default port (80), which is not the same port on which the containers running the services are listening on.

Step 5: Docker Compose Setup

Now that we have set up Nginx and configured it to act as a reverse proxy, let’s use Docker Compose to manage our containers.

First, pull the official Nginx Docker image from Docker Hub using the following command:

docker pull nginx

Next, create a file named docker-compose.yml in the root directory of the project. This file defines the services to be run and their configurations.

version: '3.8'

services:
nginx_proxy:
    image: nginx
    container_name: nginx-proxy-reverse
    depends_on:
    - user_service
    - task_service
    volumes:
    - ./nginx.conf:/etc/nginx/nginx.conf
    ports:
    - 80:80
    links:
    - user_service
    - task_service
    

# Runs the user service
user_service:
    image: user_service
    container_name: user_service_container
    

# Runs the task service
task_service:
    image: task_service
    container_name: task_service_container

This Docker Compose file defines three services: nginx, user_service, and task_service. It also has the Nginx image we pulled from Docker Hub, exposes port 80 for the Nginx container, and mounts the custom Nginx configuration file from the nginx directory. The depends_on directive ensures that the nginx service starts after the user_service and task_service are up and running.

Now that we have the services defined Docker Compose file, we can now run the following command in the root directory of the project to start the containers:

docker-compose up -d

When we initially ran our services without Nginx, we could access them directly and locally on the 127.0.0.1:5000/login endpoint for the user service, for example.

Now when you start the containers and go on Docker Desktop, notice how the requests are now routed through Nginx before reaching the services. Nginx listens on port 80, the default HTTP port, and forwards the requests to the appropriate backend service based on the URL path.

Using the user service as an example, when you access localhost/login in the browser, the request first goes to Nginx. Nginx then looks at its configuration and forwards the request to the user service running on port 5000 internally.

NGINX as proxy

In conclusion, we created two microservices built on Flask for a task management application. We then leveraged Docker containers and Nginx as a reverse proxy to direct requests to the services based on their endpoint URLS.