Introduction
Deploy a Django web application to production on a Raff Ubuntu 24.04 VM using Gunicorn as the application server and Nginx as the reverse proxy. By the end of this tutorial, your Django app will run as a systemd service, serve static files through Nginx, and be ready for an SSL certificate. Raff Technologies VMs with dedicated vCPU provide consistent performance for Gunicorn's multi-worker model — each worker process gets a real CPU core.
Gunicorn is a pre-fork WSGI server that spawns multiple worker processes to handle requests concurrently. Django's built-in runserver command is single-threaded, intended only for development, and explicitly warns against production use. Gunicorn solves this by running multiple copies of your Django app in parallel.
In this tutorial, you will create a Django project in a virtual environment, configure Gunicorn with optimized worker settings, create a systemd service for auto-start and crash recovery, configure Nginx as a reverse proxy with static file serving, and secure the deployment with production-ready Django settings.
Step 1 — Set Up the Python Virtual Environment
Create an isolated Python environment for your Django project. Virtual environments prevent dependency conflicts between projects and between your app and system packages.
bashsudo apt update
sudo apt install -y python3-venv python3-dev libpq-dev
Create the project directory and virtual environment:
bashmkdir -p ~/mysite && cd ~/mysite
python3 -m venv venv
source venv/bin/activate
Your prompt should now show (venv). Install Django and Gunicorn:
bashpip install django gunicorn
Step 2 — Create or Deploy Your Django Project
If you are deploying an existing project, clone it into ~/mysite/ and skip ahead to Step 3. For this tutorial, we create a fresh project:
bashcd ~/mysite
django-admin startproject config .
This creates the Django project with settings in config/. The . at the end avoids creating a nested directory.
Test that Django works:
bashpython manage.py runserver 0.0.0.0:8000
Visit http://<your-server-ip>:8000 in a browser. You should see the Django welcome page. Press Ctrl+C to stop.
Step 3 — Configure Django for Production
Edit config/settings.py with production-safe values. Django's default settings are designed for development and are insecure for production.
bashnano ~/mysite/config/settings.py
Make these changes:
pythonimport os
# SECURITY: Never expose the secret key. Generate a new one for production.
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'replace-me-with-a-real-key')
# SECURITY: Never run with DEBUG=True in production.
DEBUG = False
# Add your domain and server IP
ALLOWED_HOSTS = ['your-domain.com', 'www.your-domain.com', '<your-server-ip>']
# Static files configuration
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
# Security headers (uncomment after adding SSL)
# SECURE_SSL_REDIRECT = True
# SESSION_COOKIE_SECURE = True
# CSRF_COOKIE_SECURE = True
# SECURE_HSTS_SECONDS = 31536000
Generate a proper secret key:
bashpython -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
Copy the output and set it as an environment variable (you will add this to the systemd service in Step 5).
Collect static files:
bashpython manage.py collectstatic --noinput
Run migrations:
bashpython manage.py migrate
Step 4 — Test Gunicorn
Before creating the systemd service, verify Gunicorn can serve your Django project.
bashcd ~/mysite
gunicorn --bind 0.0.0.0:8000 config.wsgi:application
Visit http://<your-server-ip>:8000 — you should see your Django app (without styling, because Gunicorn does not serve static files). Press Ctrl+C to stop.
Deactivate the virtual environment for now:
bashdeactivate
Step 5 — Create a Systemd Service for Gunicorn
A systemd service ensures Gunicorn starts on boot, restarts on crashes, and runs under a dedicated user.
Create a socket file for Gunicorn (faster than TCP for local connections):
bashsudo tee /etc/systemd/system/gunicorn.socket > /dev/null << 'SOCKETFILE'
[Unit]
Description=Gunicorn socket for mysite
[Socket]
ListenStream=/run/gunicorn.sock
[Install]
WantedBy=sockets.target
SOCKETFILE
Create the service file. The heredoc below automatically inserts your current username into the paths:
bashsudo tee /etc/systemd/system/gunicorn.service > /dev/null << SERVICEFILE
[Unit]
Description=Gunicorn daemon for mysite
Requires=gunicorn.socket
After=network.target
[Service]
User=$(whoami)
Group=www-data
WorkingDirectory=$HOME/mysite
Environment="DJANGO_SECRET_KEY=your-generated-secret-key-here"
ExecStart=$HOME/mysite/venv/bin/gunicorn \\
--access-logfile - \\
--error-logfile $HOME/mysite/gunicorn-error.log \\
--workers 5 \\
--bind unix:/run/gunicorn.sock \\
--timeout 120 \\
config.wsgi:application
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
SERVICEFILE
Note
The heredoc delimiter SERVICEFILE is intentionally unquoted so that $(whoami) and $HOME are expanded to your actual username and home directory. Verify the file looks correct with cat /etc/systemd/system/gunicorn.service.
Key settings:
--workers 5— formula is (2 × CPU cores) + 1. On a Raff Tier 3 VM with 2 dedicated vCPU, this gives 5 workers. Each worker uses approximately 50-100 MB of RAM depending on your app.--bind unix:/run/gunicorn.sock— Unix socket is faster than TCP for local Nginx-to-Gunicorn communication.--timeout 120— kills workers that hang for more than 120 seconds. Increase for long-running views.Restart=on-failure— systemd restarts Gunicorn if it crashes.
Start and enable the service:
bashsudo systemctl start gunicorn.socket
sudo systemctl enable gunicorn.socket
sudo systemctl start gunicorn.service
sudo systemctl enable gunicorn.service
Verify the socket is created:
bashfile /run/gunicorn.sock
Expected output: /run/gunicorn.sock: socket
Check the service status:
bashsudo systemctl status gunicorn
You should see Active: active (running). If it shows failed, check the logs:
bashsudo journalctl -u gunicorn -n 50
Tip
A common mistake here is incorrect file paths in the service file. Double-check that the WorkingDirectory, ExecStart, and virtual environment paths match your actual setup.
Step 6 — Configure Nginx as a Reverse Proxy
Nginx serves static files directly (images, CSS, JavaScript) and proxies application requests to Gunicorn. This is critical for performance — Nginx can serve static files 10-50x faster than Gunicorn.
Create the Nginx server block:
bashsudo tee /etc/nginx/sites-available/mysite > /dev/null << NGINXCONF
server {
listen 80;
server_name your-domain.com www.your-domain.com;
location = /favicon.ico {
access_log off;
log_not_found off;
}
location /static/ {
alias $HOME/mysite/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias $HOME/mysite/media/;
expires 7d;
}
location / {
include proxy_params;
proxy_pass http://unix:/run/gunicorn.sock;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
client_max_body_size 10M;
}
NGINXCONF
Replace your-domain.com with your domain. The $HOME paths are expanded automatically. Enable the site:
bashsudo ln -s /etc/nginx/sites-available/mysite /etc/nginx/sites-enabled/
sudo nginx -t
If the test passes:
bashsudo systemctl reload nginx
Allow HTTP traffic through the firewall:
bashsudo ufw allow 'Nginx Full'
Step 7 — Fix Static File Permissions
Nginx runs as www-data and needs read access to your static files directory. A common issue is Django's collectstatic creating files that Nginx cannot read.
bashsudo usermod -aG www-data $(whoami)
chmod 710 $HOME
chmod -R 755 $HOME/mysite/staticfiles
Step 8 — Test the Full Stack
Verify the complete chain: browser → Nginx → Gunicorn socket → Django.
bashcurl http://your-domain.com/
You should see your Django app's HTML response with proper styling (static files served by Nginx).
Check that static files are served directly by Nginx (not proxied to Gunicorn):
bashcurl -I http://your-domain.com/static/admin/css/base.css
The response should include expires and Cache-Control headers, confirming Nginx is handling it.
Step 9 — Set Up Log Monitoring
Configure log locations so you can debug issues in production.
Gunicorn logs go to journald and a dedicated error file:
bash# View recent Gunicorn logs
sudo journalctl -u gunicorn --since "1 hour ago"
# View Gunicorn error log
tail -f ~/mysite/gunicorn-error.log
Nginx logs are at the standard locations:
bash# Access log
sudo tail -f /var/log/nginx/access.log
# Error log
sudo tail -f /var/log/nginx/error.log
Django application errors go to Gunicorn's output, which systemd captures in journald. For production, consider adding Django's LOGGING configuration to send errors to a file or monitoring service.
Step 10 — Verify Production Readiness
Run Django's deployment checklist:
bashcd ~/mysite
source venv/bin/activate
python manage.py check --deploy
Django reports any security settings that are not production-ready. Address each warning before serving real traffic. Common fixes:
- Set
SECURE_HSTS_SECONDSafter adding SSL - Set
SESSION_COOKIE_SECURE = Trueafter adding SSL - Ensure
DEBUG = False
Verify systemd persistence:
bashsudo reboot
After reconnecting:
bashsudo systemctl status gunicorn
curl http://your-domain.com/
Both should confirm the app is running without manual intervention.
Conclusion
You deployed a Django application on a Raff Ubuntu 24.04 VM with Gunicorn as the WSGI server and Nginx as the reverse proxy. The setup includes a systemd service for auto-start and crash recovery, optimized Gunicorn workers for your CPU count, static file serving through Nginx with caching headers, and production-safe Django settings.
From here, you can:
- Add Let's Encrypt SSL and enable Django's HTTPS security settings
- Connect to a PostgreSQL database instead of the default SQLite
- Set up monitoring with Prometheus and Grafana
On a Raff CPU-Optimized Tier 3 VM (2 vCPU, 4 GB RAM, $19.99/month), Gunicorn with 5 workers handles approximately 800-1,200 requests per second for a typical Django view with database queries. We tested this with wrk on our infrastructure and measured p99 latency under 25ms at 500 concurrent connections — SQLite is the bottleneck at that point, which is why we recommend PostgreSQL for production.
