python manage.py runserver is great during development but is meant only for testing: it handles concurrency poorly, has not been security-audited and serves static files inefficiently. In production the work is split: Gunicorn runs your Python code, and Nginx faces the outside world, handling HTTPS, static files and slow clients. Below we build that setup on an Ubuntu VPS.
How a request travels
A request to misol.uz first reaches Nginx. Paths under /static/ or /media/ are served straight from disk without touching Python; everything else goes over a Unix socket to Gunicorn, whose worker processes run Django through WSGI. Because Nginx delivers the response, a visitor on a slow connection does not tie up a Python worker. Picture a private school in Samarkand whose site misol.uz has news, timetables and a Django admin for teachers; every morning parents open the timetable at once, and this setup absorbs that spike comfortably.
Preparing the server and virtualenv
Install packages and create a dedicated system user, because running the app as root turns any code vulnerability into full server control:
sudo apt update
sudo apt install python3-venv python3-pip nginx
sudo adduser --system --group --home /srv/misol misol
sudo -u misol python3 -m venv /srv/misol/venv
sudo -u misol /srv/misol/venv/bin/pip install -r /srv/misol/requirements.txt gunicorn
We assume the code is in /srv/misol and settings live in a package called config; substitute your own name for config.wsgi if it differs.
Production settings
import os
DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
ALLOWED_HOSTS = ["misol.uz", "www.misol.uz"]
CSRF_TRUSTED_ORIGINS = ["https://misol.uz", "https://www.misol.uz"]
STATIC_URL = "/static/"
STATIC_ROOT = "/srv/misol/staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = "/srv/misol/media"
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
Secrets come from environment variables so they never land in Git history. With debug off Django no longer serves static files, so collect them for Nginx, and the proxy header tells Django the original request was HTTPS. Then run manage.py migrate and manage.py collectstatic --noinput as the misol user.
Gunicorn as a systemd service
A Gunicorn started by hand dies when your SSH session closes. Create /etc/systemd/system/misol.service:
[Unit]
Description=Gunicorn for misol.uz
After=network.target
[Service]
User=misol
Group=www-data
WorkingDirectory=/srv/misol
EnvironmentFile=/srv/misol/.env
RuntimeDirectory=misol
ExecStart=/srv/misol/venv/bin/gunicorn --workers 3 --bind unix:/run/misol/gunicorn.sock --umask 007 config.wsgi:application
Restart=on-failure
[Install]
WantedBy=multi-user.target
RuntimeDirectory creates /run/misol on each start, and Group=www-data plus --umask 007 let Nginx write to the socket. Size workers by CPU cores; 3 to 5 is a sensible start for two cores. Keep .env readable only by misol, then run sudo systemctl daemon-reload and sudo systemctl enable --now misol.
Nginx as a reverse proxy
server {
listen 80;
server_name misol.uz www.misol.uz;
client_max_body_size 20M;
location /static/ {
alias /srv/misol/staticfiles/;
}
location /media/ {
alias /srv/misol/media/;
}
location / {
proxy_pass http://unix:/run/misol/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Save it as /etc/nginx/sites-available/misol, symlink it into sites-enabled, run sudo nginx -t and reload. Then sudo certbot --nginx -d misol.uz -d www.misol.uz obtains a Let's Encrypt certificate and sets up renewal. The domain's A record must already point to the VPS; check it in the DNS section of the sayt.uz cabinet.
502 Bad Gateway and other common errors
A 502 means Nginx cannot reach Gunicorn. Start with sudo journalctl -u misol -n 50: import errors, a missing environment variable or a wrong config.wsgi path are typical. If the service is up, Permission denied in the Nginx error log points to the socket group or umask. Static 404s usually mean collectstatic was skipped or Nginx cannot read the folder. For updates, always pull code, install requirements, migrate, collect static and run sudo systemctl restart misol; scripting these steps keeps every deploy identical.