Performance Optimization Beyond Django (Gunicorn, Nginx, MariaDB, Elasticsearch, Redis)

This guide covers performance work outside Django application code for OLDP-style deployments.

It focuses on:

  • gunicorn (Python app server)

  • nginx (reverse proxy / compression / static assets)

  • MariaDB (primary relational DB)

  • Elasticsearch (search backend)

  • Redis (cache backend)

  • docker compose examples for local/prod-like setups

This document is intended as a practical checklist and starting point, not a one-size-fits-all config. Benchmark with your own traffic patterns.

2. Gunicorn (App Server)

Goals

  • Keep worker processes busy but not overloaded

  • Avoid memory bloat / worker stalls

  • Reduce tail latency under burst traffic

Example command (good starting point)

gunicorn oldp.wsgi:application \
  --bind 0.0.0.0:8000 \
  --workers 3 \
  --threads 4 \
  --worker-class gthread \
  --timeout 30 \
  --graceful-timeout 30 \
  --keep-alive 5 \
  --max-requests 1000 \
  --max-requests-jitter 100 \
  --preload \
  --access-logfile - \
  --error-logfile -

Sizing guidance

  • CPU-bound endpoints: fewer workers, fewer threads

  • I/O-bound endpoints (DB/search/cache): moderate threads help

  • Start with:

    • workers ~= CPU cores

    • threads = 2..4

  • Increase gradually while monitoring:

    • p95 latency

    • memory usage per worker

    • worker restarts / timeouts

Common mistakes

  • Too many workers causing DB connection pressure

  • Very high timeout hiding slow dependencies

  • No max-requests in long-running processes with slow memory growth

3. Nginx (Reverse Proxy, Compression, Static Files)

Goals

  • Terminate client connections efficiently

  • Compress text responses

  • Serve static files directly

  • Protect Gunicorn from slow clients

Example Nginx config (OLDP-oriented)

server {
    listen 80;
    server_name _;

    client_max_body_size 20m;

    gzip on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        application/rss+xml
        image/svg+xml;
    gzip_vary on;

    location /static/ {
        alias /srv/oldp/static/;
        access_log off;
        expires 30d;
        add_header Cache-Control "public, max-age=2592000, immutable";
    }

    location /media/ {
        alias /srv/oldp/media/;
        expires 1d;
        add_header Cache-Control "public, max-age=86400";
    }

    location / {
        proxy_pass http://app:8000;
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 5s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;

        proxy_buffering on;
        proxy_buffers 16 16k;
        proxy_busy_buffers_size 64k;

        proxy_set_header Connection "";
    }
}

Optional: Nginx micro-cache (anonymous only)

If you later want edge/proxy caching for anon traffic:

  • Cache only GET/HEAD

  • Bypass when auth/session cookies are present

  • Start with very small TTL (1-10s)

This is powerful but should be a follow-up after validating Django-side cache correctness.

4. MariaDB (Database)

Goals

  • Keep hot indexes/data in memory

  • Avoid expensive disk flush behavior that is too strict for your workload

  • Support concurrent reads from API/page traffic

High-impact areas

  • InnoDB buffer pool size

  • Connection limits / thread behavior

  • Slow query logging

  • Correct indexes (still the biggest win after memory tuning)

Notes

  • innodb_buffer_pool_size should usually be the largest memory allocation on a DB host/container.

  • For dedicated DB hosts, common targets are 50-70% of RAM.

  • In containers, ensure memory limits and DB tuning agree (don’t allocate a 2G buffer pool in a 1G-limited container).

Django/MariaDB interaction tips

  • Keep CONN_MAX_AGE reasonable (persistent connections help, but can pin too many DB connections if gunicorn workers are oversized)

  • Tune gunicorn worker count and DB max_connections together

  • Use slow query logs to drive schema/index changes before over-tuning MariaDB globals

5. Elasticsearch (Search Backend)

Goals

  • Stable low-latency search queries

  • Sufficient heap without over-allocation

  • Predictable indexing/refresh behavior

High-impact settings

  • JVM heap size (Xms, Xmx)

  • Index shard/replica counts (especially for single-node)

  • Refresh interval (during heavy indexing)

  • Host kernel setting: vm.max_map_count

Host requirement (important)

On the host (not usually in compose), set:

sudo sysctl -w vm.max_map_count=262144

Persist in /etc/sysctl.conf (or platform equivalent).

Performance troubleshooting checklist

  • Heap pressure / GC pauses

  • Slow queries by field / analyzer mismatch

  • Oversharding

  • Disk I/O saturation

  • Search timeouts causing fallback behavior in the app

6. Redis (Cache Backend)

Goals

  • Fast shared cache for Django page/API caching

  • Predictable eviction behavior

  • Optional queue/session support separation

Redis config basics

  • Set maxmemory

  • Choose an eviction policy appropriate for cache usage

    • good default for cache-only Redis: allkeys-lru

  • Disable AOF if using Redis only as ephemeral cache (depends on ops policy)

Example redis.conf snippet:

maxmemory 512mb
maxmemory-policy allkeys-lru
save ""
appendonly no

Django cache integration (example)

If/when switching from file/locmem to Redis in production:

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://redis:6379/1",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        },
        "KEY_PREFIX": "oldp",
    }
}

Operational notes

  • Monitor:

    • hit rate

    • memory usage

    • evictions

    • latency spikes

  • Keep cache key cardinality under control (host/language/path variance is correct, but measure growth)

7. Docker Compose Example (Production-Like Baseline)

This is an example docker compose stack showing the service relationships and performance-focused defaults. Adapt paths, secrets, and image tags.

compose.performance.yaml

services:
  app:
    build: .
    command: >
      gunicorn oldp.wsgi:application
      --bind 0.0.0.0:8000
      --workers 3
      --threads 4
      --worker-class gthread
      --timeout 30
      --graceful-timeout 30
      --keep-alive 5
      --max-requests 1000
      --max-requests-jitter 100
      --preload
      --access-logfile -
      --error-logfile -
    env_file:
      - .env
    environment:
      DJANGO_SETTINGS_MODULE: oldp.settings
      DJANGO_CONFIGURATION: ProdConfiguration
      DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
      DATABASE_URL: mysql://oldp:${MARIADB_PASSWORD}@db/oldp
      DJANGO_ELASTICSEARCH_URL: http://search:9200/
      DJANGO_FILE_CACHE_LOCATION: /var/tmp/django_cache
      # Example: enable Redis cache if configured in settings
      # REDIS_URL: redis://redis:6379/1
    depends_on:
      db:
        condition: service_healthy
      search:
        condition: service_started
      redis:
        condition: service_started
    expose:
      - "8000"
    volumes:
      - static_data:/srv/oldp/static
      - media_data:/srv/oldp/media
      - django_cache:/var/tmp/django_cache
    restart: unless-stopped

  nginx:
    image: nginx:1.27-alpine
    depends_on:
      - app
    ports:
      - "80:80"
    volumes:
      - ./deployment/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - static_data:/srv/oldp/static:ro
      - media_data:/srv/oldp/media:ro
    restart: unless-stopped

  db:
    image: mariadb:lts
    environment:
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_DATABASE: oldp
      MARIADB_USER: oldp
      MARIADB_PASSWORD: ${MARIADB_PASSWORD}
    command:
      - --innodb-buffer-pool-size=1G
      - --innodb-log-file-size=256M
      - --max-connections=200
      - --slow-query-log=1
      - --long-query-time=0.5
    volumes:
      - mariadb_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s
    restart: unless-stopped

  search:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.17.12
    environment:
      cluster.name: oldp
      discovery.type: single-node
      xpack.security.enabled: "false"
      ES_JAVA_OPTS: "-Xms1g -Xmx1g"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es_data:/usr/share/elasticsearch/data
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: >
      redis-server
      --maxmemory 512mb
      --maxmemory-policy allkeys-lru
      --save ""
      --appendonly no
    volumes:
      - redis_data:/data
    restart: unless-stopped

volumes:
  mariadb_data:
  es_data:
  redis_data:
  static_data:
  media_data:
  django_cache:

8. Docker Compose Tuning Notes

Separate persistent vs ephemeral volumes

  • Persistent:

    • MariaDB data

    • Elasticsearch data

    • Media uploads

  • Ephemeral / disposable:

    • Django file cache (if used)

    • Redis cache-only data (optional persistence)

Healthchecks and startup ordering

Use healthchecks for:

  • MariaDB

  • Elasticsearch (optional curl http://localhost:9200/_cluster/health)

Do not assume depends_on alone means “ready for traffic”.

10. Practical Rollout Strategy

  1. Move from runserver to gunicorn behind nginx

  2. Add Redis for shared Django cache (if not already)

  3. Tune MariaDB memory and enable slow query logs

  4. Tune Elasticsearch heap and verify host vm.max_map_count

  5. Benchmark

  6. Revisit Django-level cache TTLs and variance after observing real hit rates

11. Checklist (Quick Reference)

  • [ ] Gunicorn workers/threads sized for available CPU/RAM

  • [ ] Gunicorn max-requests enabled

  • [ ] Nginx gzip enabled

  • [ ] Nginx serves static files directly

  • [ ] MariaDB slow query log enabled

  • [ ] MariaDB InnoDB buffer pool sized appropriately

  • [ ] Elasticsearch heap set explicitly

  • [ ] Host vm.max_map_count configured

  • [ ] Redis maxmemory + eviction policy set

  • [ ] Docker volumes split by persistence needs

  • [ ] Baseline and post-change benchmarks captured