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:
- An Ubuntu 24.04 VPS. For a typical Django or Flask app, deploy an Ubuntu 24.04 Cloud VPS with 2 vCPU / 4 GB RAM — enough for 5 Gunicorn workers and a local database.
- Root or sudo access. Complete the initial Ubuntu VPS setup and security hardening first.
- A domain name with an A record pointing to your server's IP.
- A Django or Flask application ready for deployment.
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
- Ubuntu VPS initial setup guide — server fundamentals
- Security hardening guide — firewall, SSH keys, fail2ban
- Nginx reverse proxy guide — advanced configuration and caching
- Install PostgreSQL on Ubuntu VPS — production database setup
- Set up Redis on Ubuntu VPS — caching and session storage