Django is one of those frameworks where local development feels very different from production. Locally: python manage.py runserver, and you're done. In production, that same command will get you in trouble — it's a development server, not designed to handle real traffic or run unattended.
I learned this the hard way on my first Django project. The app ran fine locally but the production setup took me a whole day to figure out. Gunicorn as the application server, Nginx as the reverse proxy, PostgreSQL instead of SQLite, and static files that actually get served — none of this is obvious from Django's documentation alone.
This guide pulls all of it together in one place: the complete production deployment of a Django app on Ubuntu 22.04.
I run this on Tencent Cloud Lighthouse. The 2 vCPU / 4 GB RAM plan handles Django comfortably for most projects. A few reasons I use Lighthouse for Django specifically: the snapshot feature lets me take a point-in-time backup of the entire server — app code, database, configuration — before a tricky migration, so I have a rollback option if things go wrong. The console-level firewall also makes it easy to lock down PostgreSQL to localhost only (not expose it to the internet) while still being able to access the Django admin via HTTPS. You can also upgrade specs from the control panel as traffic grows, without migrating to a new server.
Key Takeaways
- Set
DEBUG = Falseand configureALLOWED_HOSTSbefore going live- Run
collectstaticand configure Nginx to serve static files directly- Use
python-dotenvor environment variables forSECRET_KEYand database credentials- Gunicorn runs Django workers; Nginx handles the public-facing HTTPS
- Take a database backup before running
manage.py migrateon production
Internet → Nginx → Gunicorn → Django app
↓
Static/Media files (served directly by Nginx)
↓
PostgreSQL database
| Component | Role |
|---|---|
| Nginx | Serves static/media files, proxies app requests, handles SSL |
| Gunicorn | WSGI server running Django workers |
| Django | Your application |
| PostgreSQL | Database (better suited to Django than SQLite for production) |
| systemd | Process management |
| Requirement | Notes |
|---|---|
| Cloud server | Tencent Cloud Lighthouse Ubuntu 22.04 |
| A Django project | We'll use an example project; adapt for your own |
| Domain name | Optional for IP-only testing |
ssh ubuntu@YOUR_SERVER_IP
sudo apt update && sudo apt upgrade -y
# Install dependencies
sudo apt install -y python3 python3-pip python3-venv \
libpq-dev nginx git curl
# Firewall
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo apt install -y postgresql postgresql-contrib
sudo systemctl start postgresql
sudo systemctl enable postgresql
Create a database and user for Django:
sudo -u postgres psql
CREATE DATABASE mydjangodb;
CREATE USER mydjango_user WITH PASSWORD 'choose_a_strong_password';
-- Django-recommended settings
ALTER ROLE mydjango_user SET client_encoding TO 'utf8';
ALTER ROLE mydjango_user SET default_transaction_isolation TO 'read committed';
ALTER ROLE mydjango_user SET timezone TO 'UTC';
GRANT ALL PRIVILEGES ON DATABASE mydjangodb TO mydjango_user;
\q
mkdir -p ~/apps && cd ~/apps
# Clone your project or create one
git clone https://github.com/your-username/your-django-app.git myproject
cd myproject
# Create virtual environment
python3 -m venv venv
source venv/bin/activate
# Install dependencies
pip install django gunicorn psycopg2-binary whitenoise python-dotenv
# If you have a requirements.txt:
# pip install -r requirements.txt
Create a .env file for sensitive settings:
nano .env
DEBUG=False
SECRET_KEY=generate-a-long-random-string-here
DATABASE_URL=postgresql://mydjango_user:your_password@localhost/mydjangodb
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com,YOUR_SERVER_IP
Update settings.py to read from environment variables:
import os
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = os.environ.get('SECRET_KEY')
DEBUG = os.environ.get('DEBUG', 'False') == 'True'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'mydjangodb',
'USER': 'mydjango_user',
'PASSWORD': os.environ.get('DB_PASSWORD'),
'HOST': 'localhost',
'PORT': '5432',
}
}
# Static files
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATIC_URL = '/static/'
# Whitenoise for serving static files efficiently
MIDDLEWARE = [
'whitenoise.middleware.WhiteNoiseMiddleware',
# ... your other middleware
]
Run database migrations and collect static files:
source venv/bin/activate
python manage.py migrate
python manage.py collectstatic --noinput
python manage.py createsuperuser # Create admin user
Test it runs:
python manage.py runserver 0.0.0.0:8000
# Visit http://YOUR_SERVER_IP:8000 to verify, then Ctrl+C
Test Gunicorn manually:
cd ~/apps/myproject
source venv/bin/activate
gunicorn --bind 0.0.0.0:8000 myproject.wsgi:application
# Replace "myproject" with your Django project name (the directory containing settings.py)
Visit http://YOUR_SERVER_IP:8000 to confirm it works, then stop with Ctrl+C.
Create a systemd service:
sudo nano /etc/systemd/system/gunicorn_myproject.service
[Unit]
Description=Gunicorn for Django myproject
After=network.target
[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/home/ubuntu/apps/myproject
EnvironmentFile=/home/ubuntu/apps/myproject/.env
ExecStart=/home/ubuntu/apps/myproject/venv/bin/gunicorn \
--workers 3 \
--bind unix:/run/gunicorn_myproject.sock \
--access-logfile /var/log/gunicorn_myproject_access.log \
--error-logfile /var/log/gunicorn_myproject_error.log \
myproject.wsgi:application
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl start gunicorn_myproject
sudo systemctl enable gunicorn_myproject
sudo systemctl status gunicorn_myproject
# Verify socket created
ls /run/gunicorn_myproject.sock
sudo nano /etc/nginx/sites-available/myproject
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
# Static files served directly by Nginx
location /static/ {
root /home/ubuntu/apps/myproject;
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Media files
location /media/ {
root /home/ubuntu/apps/myproject;
}
# Django via Gunicorn
location / {
include proxy_params;
proxy_pass http://unix:/run/gunicorn_myproject.sock;
}
access_log /var/log/nginx/myproject_access.log;
error_log /var/log/nginx/myproject_error.log;
}
sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
In production, Django doesn't serve static files itself — Nginx does. Make sure:
STATIC_ROOT is set in settings.pypython manage.py collectstatic has been run (it copies all static files to STATIC_ROOT)location /static/ block points to the right directoryThe staticfiles/ directory (from collectstatic) is what Nginx serves.
For media files (user uploads):
# settings.py
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
Set permissions:
sudo chown -R ubuntu:www-data ~/apps/myproject/media
sudo chmod -R 755 ~/apps/myproject/media
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Also update Django's ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS:
CSRF_TRUSTED_ORIGINS = ['https://yourdomain.com', 'https://www.yourdomain.com']
Restart Gunicorn after any settings change:
sudo systemctl restart gunicorn_myproject
After going live, a common surprise: you visit your domain and get a 400 Bad Request from Django.
The cause is almost always ALLOWED_HOSTS. In production with DEBUG=False, Django rejects any request where the Host header doesn't match a value in ALLOWED_HOSTS.
# Wrong — too restrictive
ALLOWED_HOSTS = ['localhost']
# Correct — include your actual domain
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']
# If you're testing with IP only
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com', 'YOUR_SERVER_IP']
After changing settings.py, restart Gunicorn:
sudo systemctl restart gunicorn_myproject
# Restart app after code changes
sudo systemctl restart gunicorn_myproject
# Collect static files after frontend changes
source ~/apps/myproject/venv/bin/activate
cd ~/apps/myproject
python manage.py collectstatic --noinput
# Run database migrations
python manage.py migrate
# View app logs
sudo journalctl -u gunicorn_myproject -f
tail -f /var/log/gunicorn_myproject_error.log
# View Nginx logs
tail -f /var/log/nginx/myproject_error.log
| Issue | Likely Cause | Fix |
|---|---|---|
| Connection refused | Service not running or wrong port | Check systemctl status SERVICE and verify firewall rules |
| Permission denied | Wrong file ownership or permissions | Check file ownership with ls -la and use chown/chmod to fix |
| 502 Bad Gateway | Backend service not running | Restart the backend service; check logs with journalctl -u SERVICE |
| SSL certificate error | Certificate expired or domain mismatch | Run sudo certbot renew and verify domain DNS points to server IP |
| Service not starting | Config error or missing dependency | Check logs with journalctl -u SERVICE -n 50 for specific error |
| Out of disk space | Logs or data accumulation | Run df -h to identify usage; clean logs or attach CBS storage |
| High memory usage | Too many processes or memory leak | Check with htop; consider upgrading instance plan if consistently high |
| Firewall blocking traffic | Port not open in UFW or Lighthouse console | Open port in Lighthouse console firewall AND sudo ufw allow PORT |
What is the difference between Django development and production servers?
manage.py runserver is single-threaded, unoptimized for traffic, and not designed for public internet exposure. Production requires Gunicorn (WSGI server) + Nginx (reverse proxy) + a proper database like PostgreSQL.
Why use PostgreSQL instead of SQLite for production Django?
SQLite doesn't handle concurrent writes well and isn't designed for multi-user production environments. PostgreSQL is production-grade, handles concurrent access properly, and is what Django is optimized for.
How do I run Django database migrations on the server?
SSH into the server and run python manage.py migrate from your project directory with the virtual environment activated. Always take a database backup before running migrations.
What is ALLOWED_HOSTS in Django and how do I configure it?
ALLOWED_HOSTS is a security setting that specifies which hostnames Django will serve. Set it to your domain and server IP: ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com', 'YOUR_SERVER_IP']. Django returns 400 for requests to unlisted hosts.
python manage.py collectstatic to gather all static files into STATIC_ROOT. Configure Nginx to serve that directory directly (Nginx is much faster at serving static files than Python).Deploy your Django app today:
👉 Tencent Cloud Lighthouse — Python/Django-ready Ubuntu VPS
👉 View current pricing and promotions
👉 Explore all active deals and offers