Python web frameworks like Django and Flask are among the most popular choices for building web applications, APIs, and backend services. But running python manage.py runserver or flask run in production is a serious mistake — those development servers are single-threaded, unoptimized, and lack the robustness needed for real traffic.

The production stack for Python web applications is Gunicorn (a WSGI HTTP server) behind Nginx (a reverse proxy). Gunicorn handles Python request processing with multiple worker processes, while Nginx handles SSL termination, static file serving, request buffering, and connection management. This guide covers both Django and Flask deployments on Ubuntu 24.04, from system preparation through to SSL and static files.

Prerequisites

Before starting, you need:

System Preparation

Update the system and install Python development dependencies:

sudo apt update && sudo apt upgrade -y
sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libpq-dev

Verify the Python version:

python3 --version
# Python 3.12.3

Ubuntu 24.04 ships with Python 3.12, which is well-supported by both Django 5.x and Flask 3.x. The libpq-dev package is required if you plan to use PostgreSQL (covered in our PostgreSQL installation guide).

Create a dedicated user for your application if you haven't already:

sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG sudo deploy

Creating a Virtual Environment

Always use a virtual environment for Python applications. It isolates your project's dependencies from the system Python and from other projects on the same server.

sudo mkdir -p /var/www/myapp
sudo chown deploy:deploy /var/www/myapp
su - deploy
cd /var/www/myapp

Create and activate the virtual environment:

python3 -m venv venv
source venv/bin/activate

Your shell prompt should now show (venv) at the beginning. Verify pip is available inside the virtual environment:

which pip
# /var/www/myapp/venv/bin/pip

pip install --upgrade pip setuptools wheel

Installing Your Application and Dependencies

Option A: Git Clone

cd /var/www/myapp
git clone git@github.com:youruser/yourapp.git src
cd src
pip install -r requirements.txt

Option B: Rsync from Local Machine

From your local development machine:

rsync -avz --exclude='venv' --exclude='__pycache__' --exclude='.git' --exclude='.env' \
  ./myapp/ deploy@your-server-ip:/var/www/myapp/src/

Then on the server:

cd /var/www/myapp/src
source ../venv/bin/activate
pip install -r requirements.txt

For Django Applications

Run initial setup commands:

python manage.py migrate
python manage.py collectstatic --noinput
python manage.py createsuperuser

For Flask Applications

If you use Flask-Migrate for database migrations:

flask db upgrade

Gunicorn Setup and Testing

Install Gunicorn inside the virtual environment:

pip install gunicorn

Test Gunicorn with your application.

For Django:

cd /var/www/myapp/src
gunicorn myproject.wsgi:application --bind 0.0.0.0:8000

For Flask:

cd /var/www/myapp/src
gunicorn app:app --bind 0.0.0.0:8000

Where app:app means "import the app object from the app.py module." If your Flask application uses a factory function:

gunicorn "app:create_app()" --bind 0.0.0.0:8000

Verify it responds:

curl http://localhost:8000

Stop Gunicorn with Ctrl+C. We'll configure it as a systemd service next.

Gunicorn Worker Optimization

The number of Gunicorn workers directly determines how many concurrent requests your application can handle. The standard formula is:

workers = (2 × CPU_CORES) + 1

On a 2 vCPU server: (2 × 2) + 1 = 5 workers. On a 4 vCPU server: (2 × 4) + 1 = 9 workers.

Each worker is an independent OS process that handles one request at a time (for synchronous workers). More workers means more concurrent capacity, but each worker consumes RAM — typically 50-150 MB per worker depending on your application.

For I/O-bound applications (lots of database queries, API calls), you can use asynchronous workers instead:

pip install gevent

gunicorn myproject.wsgi:application \
  --workers 5 \
  --worker-class gevent \
  --worker-connections 1000 \
  --bind 0.0.0.0:8000

Why dedicated CPU matters for Gunicorn workers: The (2 × CPU) + 1 formula assumes each CPU core delivers consistent performance. On shared VPS infrastructure, CPU time is shared between tenants. When a neighbor runs a CPU-intensive workload, your Gunicorn workers slow down proportionally. A Dedicated VPS (VDS) gives you physically isolated CPU cores, so every worker gets predictable, uncontested compute. This is particularly important for Django applications doing template rendering, serialization, or data processing — workloads that are CPU-bound and directly affected by CPU contention.

Systemd Service File for Gunicorn

Create a systemd service so Gunicorn starts automatically on boot, restarts on failure, and integrates with system logging.

For Django:

sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=Gunicorn daemon for myapp (Django)
Requires=myapp.socket
After=network.target

[Service]
User=deploy
Group=www-data
WorkingDirectory=/var/www/myapp/src
ExecStart=/var/www/myapp/venv/bin/gunicorn \
    --access-logfile - \
    --error-logfile /var/log/gunicorn/myapp-error.log \
    --workers 5 \
    --bind unix:/run/gunicorn/myapp.sock \
    --timeout 120 \
    --graceful-timeout 30 \
    --max-requests 1000 \
    --max-requests-jitter 50 \
    myproject.wsgi:application

Restart=on-failure
RestartSec=5s

# Environment variables
Environment="DJANGO_SETTINGS_MODULE=myproject.settings.production"
EnvironmentFile=/var/www/myapp/.env

[Install]
WantedBy=multi-user.target

For Flask:

[Unit]
Description=Gunicorn daemon for myapp (Flask)
Requires=myapp.socket
After=network.target

[Service]
User=deploy
Group=www-data
WorkingDirectory=/var/www/myapp/src
ExecStart=/var/www/myapp/venv/bin/gunicorn \
    --access-logfile - \
    --error-logfile /var/log/gunicorn/myapp-error.log \
    --workers 5 \
    --bind unix:/run/gunicorn/myapp.sock \
    --timeout 120 \
    --graceful-timeout 30 \
    --max-requests 1000 \
    --max-requests-jitter 50 \
    app:app

Restart=on-failure
RestartSec=5s

EnvironmentFile=/var/www/myapp/.env

[Install]
WantedBy=multi-user.target

Create the accompanying socket file:

sudo nano /etc/systemd/system/myapp.socket
[Unit]
Description=Gunicorn socket for myapp

[Socket]
ListenStream=/run/gunicorn/myapp.sock
SocketUser=www-data

[Install]
WantedBy=sockets.target

Create the required directories:

sudo mkdir -p /run/gunicorn
sudo mkdir -p /var/log/gunicorn
sudo chown deploy:www-data /run/gunicorn
sudo chown deploy:deploy /var/log/gunicorn

Create a tmpfiles configuration to recreate the socket directory after reboot:

sudo nano /etc/tmpfiles.d/gunicorn.conf
d /run/gunicorn 0755 deploy www-data -

The --max-requests 1000 setting tells Gunicorn to restart each worker after it handles 1000 requests. This prevents memory leaks from gradually consuming all server RAM. The --max-requests-jitter 50 randomizes the restart point so all workers don't restart simultaneously.

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable myapp.socket
sudo systemctl start myapp.socket
sudo systemctl enable myapp.service
sudo systemctl start myapp.service

Check the status:

sudo systemctl status myapp.service

Verify the socket is active:

sudo systemctl status myapp.socket
file /run/gunicorn/myapp.sock

Test that requests through the socket work:

curl --unix-socket /run/gunicorn/myapp.sock http://localhost

Nginx Reverse Proxy Configuration

For a complete deep-dive into Nginx configuration, see our Nginx reverse proxy guide. Here's the configuration for Gunicorn.

Install Nginx if you haven't already:

sudo apt install -y nginx

Create the server block:

sudo nano /etc/nginx/sites-available/myapp

Django Configuration:

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    client_max_body_size 10M;

    # Django static files (collected via collectstatic)
    location /static/ {
        alias /var/www/myapp/src/staticfiles/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Django media files (user uploads)
    location /media/ {
        alias /var/www/myapp/src/media/;
        expires 7d;
        add_header Cache-Control "public";
    }

    # Proxy all other requests to Gunicorn
    location / {
        proxy_set_header Host $http_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_redirect off;
        proxy_pass http://unix:/run/gunicorn/myapp.sock;
    }

    # Block dotfiles
    location ~ /\. {
        deny all;
    }
}

Flask Configuration:

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    client_max_body_size 10M;

    # Static files served directly by Nginx
    location /static/ {
        alias /var/www/myapp/src/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Proxy to Gunicorn
    location / {
        proxy_set_header Host $http_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_redirect off;
        proxy_pass http://unix:/run/gunicorn/myapp.sock;
    }

    # Block dotfiles
    location ~ /\. {
        deny all;
    }
}

Enable the site and test:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default  # Remove default site if present
sudo nginx -t
sudo systemctl reload nginx

SSL with Certbot

Install Certbot with the Nginx plugin:

sudo apt install -y certbot python3-certbot-nginx

Obtain and install the SSL certificate:

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot automatically modifies your Nginx configuration to include SSL listeners and redirects. Verify auto-renewal:

sudo systemctl status certbot.timer
sudo certbot renew --dry-run

After Certbot, add security headers inside the server block:

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

For Django, also update settings.py to trust the proxy headers:

SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']

Static Files Serving

Django Static Files

Django's collectstatic command gathers all static files from your apps into a single directory. Configure it in settings.py:

STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'

MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

Run the collection command:

python manage.py collectstatic --noinput

This copies all static files to /var/www/myapp/src/staticfiles/, which Nginx serves directly without touching Gunicorn. This is a significant performance improvement — Nginx handles static files orders of magnitude faster than Python.

Set proper permissions:

sudo chown -R deploy:www-data /var/www/myapp/src/staticfiles/
sudo chown -R deploy:www-data /var/www/myapp/src/media/

Flask Static Files

Flask serves static files from a static/ directory by default. With Nginx configured to serve /static/ directly, these never reach Gunicorn. Ensure your Flask templates use url_for('static', filename='...') for all static references.

MassiveGRID Ubuntu VPS Includes

Ubuntu 24.04 LTS pre-installed · Proxmox HA cluster with automatic failover · Ceph 3x replicated NVMe storage · Independent CPU/RAM/storage scaling · 12 Tbps DDoS protection · 4 global datacenter locations · 100% uptime SLA · 24/7 human support rated 9.5/10

→ Deploy a self-managed VPS — from $1.99/mo
→ Need dedicated resources? — from $19.80/mo
→ Want fully managed hosting? — we handle everything

Database Considerations

Both Django and Flask commonly start with SQLite during development. For production, migrate to PostgreSQL — it handles concurrent writes, supports advanced queries, and scales with your application.

Django with PostgreSQL

Install the PostgreSQL adapter:

pip install psycopg2-binary

Update settings.py:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'myapp_db',
        'USER': 'myapp_user',
        'PASSWORD': 'your-secure-password',
        'HOST': 'localhost',
        'PORT': '5432',
        'CONN_MAX_AGE': 600,
        'OPTIONS': {
            'connect_timeout': 10,
        }
    }
}

Flask with PostgreSQL (SQLAlchemy)

pip install psycopg2-binary Flask-SQLAlchemy
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://myapp_user:password@localhost:5432/myapp_db'
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'pool_size': 10,
    'pool_recycle': 300,
    'pool_pre_ping': True
}

For a complete PostgreSQL setup — installation, user creation, production tuning, and automated backups — follow our PostgreSQL on Ubuntu VPS guide.

For caching and session storage, adding Redis to your stack dramatically improves response times. See our Redis setup guide for installation and configuration with Django and Flask.

Environment Variables

Create a .env file for your application secrets:

sudo nano /var/www/myapp/.env
DJANGO_SECRET_KEY=your-random-50-character-string-here
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
DATABASE_URL=postgresql://myapp_user:password@localhost:5432/myapp_db
REDIS_URL=redis://localhost:6379/0
EMAIL_HOST=smtp.example.com
EMAIL_HOST_USER=noreply@yourdomain.com
EMAIL_HOST_PASSWORD=your-smtp-password

Set restrictive permissions:

sudo chmod 600 /var/www/myapp/.env
sudo chown deploy:deploy /var/www/myapp/.env

The systemd service file references this via EnvironmentFile=/var/www/myapp/.env, making all variables available to Gunicorn and your application.

For Django, use django-environ or python-decouple to read these values:

pip install django-environ
import environ

env = environ.Env()
environ.Env.read_env(BASE_DIR / '.env')

SECRET_KEY = env('DJANGO_SECRET_KEY')
DEBUG = env.bool('DJANGO_DEBUG', default=False)
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')

Deployment Script

Automate deployments with a script:

#!/bin/bash
set -e

APP_DIR="/var/www/myapp/src"
VENV="/var/www/myapp/venv"

echo "==> Pulling latest code..."
cd $APP_DIR
git pull origin main

echo "==> Activating virtual environment..."
source $VENV/bin/activate

echo "==> Installing dependencies..."
pip install -r requirements.txt

echo "==> Running migrations..."
python manage.py migrate --noinput

echo "==> Collecting static files..."
python manage.py collectstatic --noinput

echo "==> Restarting Gunicorn..."
sudo systemctl restart myapp.service

echo "==> Deployment complete!"
sudo systemctl status myapp.service

Troubleshooting

502 Bad Gateway

This means Nginx can't reach Gunicorn. Check if the service is running:

sudo systemctl status myapp.service
sudo journalctl -u myapp.service -n 50

Verify the socket exists:

file /run/gunicorn/myapp.sock

Permission Denied on Socket

Ensure Nginx's user (www-data) can read the socket:

ls -la /run/gunicorn/myapp.sock
# Should show: srw-rw-rw- ... deploy www-data

Static Files Return 404

Verify the alias path in your Nginx configuration matches the actual directory and that collectstatic has been run.

Workers Timing Out

Increase the --timeout value in the systemd service file. The default is 30 seconds. For long-running requests (report generation, file processing), set it to 120 or higher.

Skip the Infrastructure Management?

Running a Django or Flask application in production requires ongoing management: operating system patches, Python version updates, Gunicorn worker tuning, Nginx configuration, SSL certificate renewals, log rotation, database maintenance, and security monitoring. If you'd rather focus on building features, MassiveGRID's Managed Dedicated Cloud Servers handle all infrastructure administration. Your application runs on Proxmox HA clusters with automatic failover and triple-replicated Ceph NVMe storage, with 24/7 expert support handling everything from OS updates to incident response.

Next Steps