Skip to content

Python / WSGI Deployment SOP

Architecture

User → Nginx (host) → Gunicorn container (8000) → PostgreSQL container (5432)

Project Structure

project/
├── app/
├── config/
├── manage.py / wsgi.py
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── .github/workflows/deploy.yml

Dockerfile (Django)

Dockerfile
FROM python:3.11-slim AS base

WORKDIR /app

RUN apt update && apt install -y --no-install-recommends \
    gcc libpq-dev && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN python manage.py collectstatic --noinput

EXPOSE 8000

CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]

Dockerfile (Flask)

Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4"]

Docker Compose

docker-compose.yml
services:
  db:
    image: postgres:15
    container_name: python-db
    restart: unless-stopped
    env_file: .env
    volumes:
      - ./db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  app:
    build: .
    container_name: python-app
    restart: unless-stopped
    ports:
      - "127.0.0.1:8000:8000"
    volumes:
      - static:/app/static
      - media:/app/media
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    command: >
      sh -c "python manage.py migrate --noinput &&
             gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4"

  redis:
    image: redis:alpine
    container_name: python-redis
    restart: unless-stopped

volumes:
  static:
  media:

Environment File

.env
DEBUG=False
SECRET_KEY=your-secret-key
ALLOWED_HOSTS=example.com,api.example.com

DB_ENGINE=django.db.backends.postgresql
DB_NAME=myapp
DB_USER=myapp_user
DB_PASSWORD=strong_password
DB_HOST=db
DB_PORT=5432

POSTGRES_DB=myapp
POSTGRES_USER=myapp_user
POSTGRES_PASSWORD=strong_password

REDIS_URL=redis://redis:6379/0

EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_HOST_USER=admin@example.com
EMAIL_HOST_PASSWORD=password

Nginx Configuration

server {
    listen 443 ssl http2;
    server_name api.example.com;

    client_max_body_size 100M;

    location /static/ {
        alias /opt/python-app/static/;
        expires 30d;
    }

    location /media/ {
        alias /opt/python-app/media/;
        expires 7d;
    }

    location / {
        proxy_pass http://127.0.0.1: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;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Django-Specific

Static Files

# Collect static files
docker compose exec app python manage.py collectstatic --noinput

# Check
docker compose exec app ls static/

Migrations

# Auto-apply on startup (set in docker-compose command)
# Or run manually:
docker compose exec app python manage.py migrate --noinput

# Create migrations (dev only)
docker compose exec app python manage.py makemigrations

Management Commands

# Django shell
docker compose exec app python manage.py shell

# Create superuser
docker compose exec app python manage.py createsuperuser

# Dump data
docker compose exec app python manage.py dumpdata > data.json

# Load data
docker compose exec app python manage.py loaddata data.json

Gunicorn Tuning

# Workers = (2 × CPU cores) + 1
gunicorn config.wsgi:application \
  --bind 0.0.0.0:8000 \
  --workers 4 \
  --worker-class sync \
  --timeout 120 \
  --max-requests 1000 \
  --max-requests-jitter 50 \
  --log-level info \
  --access-logfile - \
  --error-logfile -

Systemd Service (Without Docker)

/etc/systemd/system/gunicorn.service
[Unit]
Description=Gunicorn daemon
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/app
ExecStart=/usr/local/bin/gunicorn config.wsgi:application \
  --workers 4 \
  --bind unix:/run/gunicorn.sock

[Install]
WantedBy=multi-user.target

GitHub Actions

name: Deploy Django

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /opt/python-app
            git pull origin main
            docker compose build
            docker compose up -d --force-recreate
            docker compose exec -T app python manage.py migrate --noinput
            docker compose exec -T app python manage.py collectstatic --noinput
            docker image prune -f

Verification

  • Docker containers running
  • Gunicorn listening on port 8000
  • Nginx proxies to Gunicorn
  • Static files served correctly
  • Database migrations applied
  • Admin accessible
  • Media uploads working
  • GitHub Actions deploy succeeds