Posts Quizzes AI Interviewer Feedback
Login Register

Django

Filtering by tag: Django Clear
TECH

Gunicorn and Nginx Setup for Serving the Web App

Karen Feb 4, 2026

In production environments, instead of using the default Django server, which is not secure or optimized for real traffic, we typically use Gunicorn for better security and scalability. Gunicorn is a production-ready WSGI application server for Python, which runs Django applications by handling incoming HTTP requests and passing them to Django for processing using multiple worker processes. It efficiently 

  • Manages multiple worker processes
  • Handles concurrent requests
  • Interfaces Django with web servers (like Nginx)
  • Is designed for stability and performance

Gunicorn, however, does not server static files (CSS and JavaScript). For this reason, it is commonly combined with Nginx, a high-performance web server and a reverse proxy. Nginx forwards dynamic requests to Gunicorn while serving static files directly, which significantly improves performance. Some of Nginx's key functionality include

  • Serves static and media files efficiently
  • Acts as a reverse proxy to Gunicorn
  • Handles SSL/TLS (HTTPS)
  • Provides load balancing and caching
  • Extremely fast and memory-efficient

This post concentrates on providing a minimal working Nginx configuration for serving static files, applying basic rate limiting, and running both Gunicorn and Nginx inside Docker containers.

Collecting Static Files

To ensure static files are properly served, we first need to collect them into a directory that Nginx can use.

In Django settings file, we add:

STATIC_ROOT = BASE_DIR / "staticfiles"

This setting tells Django where to place all static files (CSS, JS, images) when running collectstatic. After this, Django knows the final destination for static assets.

To gather all static files, run:

python manage.py collectstatic

This command gathers static files from:

  • each Django app

  • STATICFILES_DIRS
  • Django admin

and copies them all into STATIC_ROOT (src/staticfiles/).  

Nginx Container 

We create an nginx directory and a conf.d directory inside the directory. 

The staticfiles directory is mapped to /app/static inside the container so that Nginx can serve static content directly.

nginx:
    image: nginx:latest
    container_name: nginx
    ports:
      - "80:80"
    depends_on:
      - web
    volumes:
      - ./src/nginx/conf.d:/etc/nginx/conf.d
      - ./src/nginx/logs:/var/log/nginx
      - ./src/staticfiles:/app/static
    restart: always
    networks:
      - myproject-net

Nginx Configuration 

Next, we configure Nginx. conf.d contains a default.conf file, which contains the Nginx configuration. Static files are served directly from /app/static/, while all other requests are forwarded to port 8000, where Gunicorn serves the Django application.

It is also a good idea to put rate limitation to your application (More details cab be found on Rate Limiting with NGINX – NGINX Community Blog).

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;

server {
    listen 80;

    location /static/ {
        alias /app/static/;
    }

    location / {
        limit_req zone=mylimit burst=20 nodelay;
        proxy_pass http://web:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

The configuration does the following

  • limit_req_zone: Defines a shared memory zone used for request rate limiting.
  • $binary_remote_addr: Uses the client’s IP address (in binary form) as the key, meaning rate limiting is applied per client IP.
  • zone=mylimit:10m: Names the zone mylimit and allocates 10 mb of shared memory.
  • rate=10r/s: Allows 10 requests per second per IP.
  • listen 80: Nginx listens for incoming HTTP traffic on port 80.
  • location /static/: Matches all requests starting with /static/. Serves files directly from /app/static/ inside the container and bypasses the backend app (Gunicorn).
  • location /: matches all other requests.
  • limit_req zone=mylimit: Applies the previously defined rate-limit zone.
  • burst=20: Allows up to 20 requests to exceed the rate temporarily. Requests in the burst are not delayed. If the burst limit is exceeded, requests are immediately rejected (HTTP 503)
  • proxy_pass http://web:8000: Forwards requests to the backend service. web is the Docker service name, port 8000 is where Gunicorn is listening.
  • proxy_set_header Host $host: Passes the original Host header to the backend.
  • proxy_set_header X-Real-IP $remote_addr: Sends the client’s real IP address to the backend.
  • proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for: Appends the client IP to the X-Forwarded-for chain (A list of IP addresses representing the full proxy chain. There may be multiple depending on the number of proxies).

Web (Gunicorn) and Nginx Containers

The final configuration for web and nginx containers looks like the following. Port 8000 for web service is exposed only inside the Docker network.

services:
  web:
    container_name: web
    command: sh -c "gunicorn project.wsgi:application --workers 4 --threads 10 --bind 0.0.0.0:8000"
    image: project:latest
    ports:
      - "8000"
    volumes:
      - ./src:/src
    networks:
      - myproject-net

  nginx:
      image: nginx:latest
      container_name: nginx
      ports:
        - "80:80"
      depends_on:
        - web
      volumes:
        - ./src/nginx/conf.d:/etc/nginx/conf.d
        - ./src/nginx/logs:/var/log/nginx
        - ./src/staticfiles:/app/static
      restart: always
      networks:
        - myproject-net


networks:
  myproject-net:
    external: true

This Docker Compose setup defines a two-container architecture using Nginx as a reverse proxy and Gunicorn to run the Django application.

The web service runs the Django application using Gunicorn. It starts Gunicorn with four worker processes and ten threads per worker, listening on port 8000 inside the container. The application code is mounted from the host into the container, allowing changes to the source code without rebuilding the image. 

The nginx service acts as the public entry point. It listens on port 80 of the host machine and forwards incoming HTTP requests to the web service. Nginx loads its configuration from a mounted directory on the host, stores its logs on the host for easier access, and directly serves static files from a mounted static directory instead of passing those requests to the Django application. The container is configured to restart automatically if it stops unexpectedly.

Both services are connected to an externally managed Docker network.

Read more
TECH

SSE Event Streaming With Redis

Karen Feb 4, 2026

Event streaming is a way of processing data as a continuous flow of events rather than as one-time requests. Each event represents something that happened (e.g., “user signed up”, “order placed”), and systems can react to these events in real time or near real time. Event streaming enables systems to publish, consume, store, and process events continuously and asynchronously, making it well suited for real-time features such as notifications, dashboards, and live updates.

Pub/Sub Model

The pub/sub model is a messaging pattern where publishers send messages and subscribers (clients) receive them. Redis provides such mechanism using channels, where messages published to a channel are delivered to all active subscribers.

Messages in Redis pub/sub are not persisted, meaning that if a subscriber is offline, it will miss any messages published during that time. There is also no built-in replay support. Despite these limitations, Redis pub/sub is extremely efficient and well suited for real-time notifications, such as chat systems or live status updates.

Streaming API (View)

In this post we will implement a Django Server-Sent Events (SSE) endpoint that streams messages from a Redis pub/sub channel to connected clients in real-time.

The stream_events view subscribes to the demo_stream Redis channel and continuously listens for new messages. Whenever a message arrives, it is yielded as an SSE-formatted response using Django’s StreamingHttpResponse.

import redis
from django.http import StreamingHttpResponse
import time

def stream_events(request):
    r = redis.Redis(host="redis", port=6379, db=0)
    pubsub = r.pubsub()
    pubsub.subscribe("demo_stream")

    def event_stream():
        try:
            print("connected")
            while True:
                message = pubsub.get_message(timeout=1)
                if message and message["type"] == "message":
                    data = message["data"].decode("utf-8")
                    print(data)
                    yield f"data: {data}\n\n"
                time.sleep(0.01)
        except GeneratorExit:
            # Client disconnected
            pass
        finally:
            pubsub.close()
    response = StreamingHttpResponse(
        event_stream(), content_type="text/event-stream")
    response['Cache-Control'] = 'no-cache'
    return response

This implementation keeps the HTTP connection open and continuously pushes updates to the client whenever new messages are published to Redis.

Publishing Messages

Messages can be published to Redis using redis-cli or a Python Redis client, and any client visiting /stream/ will receive them live.

From the Redis CLI running inside Docker, messages can be published with 

docker exec -it redis redis-cli publish demo_stream "Test Event"

while from the Python Redis client, a sample example looks like this:

import redis, time

r = redis.Redis(host="127.0.0.1", port=6379, db=0)
for i in range(5):
   msg = f"Message #{i}"        
   r.publish("demo_stream", msg)
   print("Published:", msg)     
   time.sleep(1)

Each published message is immediately pushed to all connected SSE clients.

Accessing the Stream

The streaming url is 

path('stream/', stream_events, name="stream")

When visiting /stream/ in the browser (or via a compatible client), any published messages will appear in real time, as long as the client remains connected.

Note on Sync vs Async

This view runs in synchronous mode. If the Django application is running under an async server (like Daphne or Uvicorn), then a synchronous streaming view that uses blocking operations may block the event loop. This can cause the entire application to become unresponsive. To avoid this, make sure that either:

  • the whole application runs in synchronous mode, or

  • the view is rewritten to be fully async and non-blocking.

Read more
TECH

Sync and Async API Performance Comparison

Karen Jan 24, 2026

Asynchronous APIs are designed to handle many concurrent requests efficiently by avoiding thread blocking I/O operations (such as database queries, network calls, or file access). Instead of assigning one worker per request, async APIs use an event loop to switch between tasks, allowing better resource utilization under high concurrency.

In this article we will compare the performance of a sync django view running with a gunicorn server and an async django view running with an async uvicorn server. In both cases we call a test api and although it is expected for the async view running in a event loop and not being constrained by CPU threads to be significaltly faster, the results show the opposite.

To test the performance, we use hey load testing tool, which can be installed on Linux systems using:

sudo apt install hey

Defining Views

We define two views, sync_view and an async_view, both calling the same external API endpoint.

import requests
from django.http import JsonResponse
import httpx


def sync_view(request):
    r = requests.get("https://jsonplaceholder.typicode.com/todos/1")
    return JsonResponse(r.json())


async def async_view(request):
    async with httpx.AsyncClient() as client:
        r = await client.get("https://jsonplaceholder.typicode.com/todos/1")
    return JsonResponse(r.json())

The routes are:

/sync/test → sync view
/async/test → async view

and the corresponding URL mappings are:

path('async/test', async_view, name="async-test"),
path('sync/test', sync_view, name="sync-test"),

Load Testing Setup

The tests were run inside Docker containers using:

  • Gunicorn for the sync view
  • Uvicorn for the async view

Sync View Testing

To test the sync view performance (sync/test). We first run the Django server with Gunicorn with 4 workers and 10 threads per worker, This means simutanously 40 requests can be served.

gunicorn project.wsgi:application --workers 4 --threads 10 --bind 0.0.0.0:8000

We send 150 requests 30 of them being concurrent, 300 requests with 50 concurrency and 1000 requests with 100 concurency and log the results.

Async View Testing 

To test the async view performance (async/test), we run the Django server with uvicorn. 

uvicorn project.asgi:application --workers 4 --host 0.0.0.0 --port 8000

Terminology

  • Requests Per Second (RPS) measures the number of requests a server is able to handle and complete per second under a given load.

  • Average latency shows how long a request takes on average.

  • P95 latency means that 95% of requests completed faster than a given value (for example, 1.22s).

Results

Testing results are presented in the table bellow. 

Test Mode RPS Avg latency P95 latency
150 / 30 Sync 41.3 0.45s 1.22s
  Async 35.0 0.69s 1.52s
300 / 50 Sync 86.2 0.46s 0.93s
  Async 38.1 0.94s 1.69s
1000 / 100 Sync 81.4 0.92s 1.88s
  Async 42.0 1.75s 3.04s

The results show that sync view consistently outperforms the async view, as the the sync view has higher RPS, lower average latency and better tail latency.

Takeaway

Async view does not automatically imply faster performance, and for single outbound API calls at moderate load, sync views can often be faster, simpler, and more predictable. 

In this case, the sync setup performs well because Gunicorn provides enough threads to efficiently wait for I/O without overwhelming the system.

Read more