Host a Python Django or Flask App on a VPS with Gunicorn and Nginx
Deploy a Python web app properly: virtual environment, Gunicorn as the app server, systemd to keep it running and Nginx in front. Safe, copyable commands.
Django and Flask both ship with a built-in development server, and both documentations are clear that it is not for production. A production setup uses a proper application server (Gunicorn is a popular choice), a process supervisor to keep it alive, and Nginx in front to handle HTTPS and static files. The pieces are the same for both frameworks, so this guide covers them together.
The three parts are:
- Gunicorn is a WSGI server that runs several worker processes of your app.
- systemd starts Gunicorn at boot and restarts it if it dies.
- Nginx receives public traffic, serves static and media files directly, and passes everything else to Gunicorn on a local address.
1. Set up the project and a virtual environment
Install Python tooling and create an isolated environment so your app's packages do not interfere with the system's:
sudo apt install python3-venv python3-pip
sudo mkdir -p /srv/myapp
sudo chown $USER:$USER /srv/myapp
cd /srv/myapp
git clone https://example.com/you/myapp.git src
python3 -m venv venv
./venv/bin/pip install -r src/requirements.txt gunicorn
Replace the repository URL with yours. Pin versions in requirements.txt so every deployment installs the same thing. Using the venv's own pip and python by path avoids "activate" steps in scripts and unit files.
2. Production settings and a manual test
For Django, the settings that matter most are:
DEBUG = False, because debug mode shows detailed error pages and settings to visitors.ALLOWED_HOSTSset to your domain names.SECRET_KEYread from an environment variable instead of the repository.STATIC_ROOTdefined, then run./venv/bin/python src/manage.py collectstaticso Nginx can serve the files.- Run
manage.py check --deploy, which lists recommended security settings.
For Flask, never run with debug=True in production (the interactive debugger can allow code execution), and load the secret key and database settings from the environment as well.
Test Gunicorn by hand
Before involving systemd, check that the app starts:
# Django (module path depends on your project name)
./venv/bin/gunicorn --chdir src --bind 127.0.0.1:8000 myproject.wsgi:application
# Flask (module "app", variable "app")
./venv/bin/gunicorn --chdir src --bind 127.0.0.1:8000 app:app
From a second terminal, curl -I http://127.0.0.1:8000 should return a response. Stop Gunicorn with Ctrl+C when it works.
3. Create the systemd service
Create /etc/systemd/system/myapp.service:
[Unit]
Description=Gunicorn for myapp
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/srv/myapp/src
EnvironmentFile=-/etc/myapp.env
ExecStart=/srv/myapp/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 myproject.wsgi:application
Restart=on-failure
[Install]
WantedBy=multi-user.target
Gunicorn's documentation suggests starting from roughly two times the number of CPU cores plus one, but on a small VPS memory is usually the real limit, since each worker holds a full copy of your app. Start with two or three, then adjust from measurement. Enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo journalctl -u myapp -n 50
Make sure the www-data user can read the code and any directories the app writes to, such as uploads.
4. Nginx in front
server {
listen 80;
server_name app.example.com;
location /static/ {
alias /srv/myapp/src/staticfiles/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $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;
}
}
Test with sudo nginx -t, reload, and add HTTPS with Certbot. Because TLS ends at Nginx, Django needs to know the original scheme; set SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') only when your proxy always sets that header, so clients cannot spoof it.
Two Gunicorn details are worth knowing. First, the default worker timeout is 30 seconds; a request that takes longer is killed, which usually points to slow code that should move into a background job rather than a bigger timeout. Second, you can bind to a Unix socket (for example --bind unix:/run/myapp.sock) and use proxy_pass http://unix:/run/myapp.sock; in Nginx. TCP on localhost is simpler to debug, so start there.
Database, updates and common problems
Media files that users upload need the same treatment as static files: store them outside the code folder, give the app user write access, and add a matching Nginx location /media/ block so they are served directly instead of through Python.
Use PostgreSQL or MySQL/MariaDB for real projects rather than SQLite, particularly when several workers write at once. To update the app, pull the code, install requirements, run migrations (manage.py migrate for Django), collect static files and run sudo systemctl restart myapp. A tidy update script keeps the steps in order (run from the project folder, adapting paths and service name to yours):
git pull
../venv/bin/pip install -r requirements.txt
../venv/bin/python manage.py migrate
../venv/bin/python manage.py collectstatic --noinput
sudo systemctl restart myapp
A 502 from Nginx means Gunicorn is not running or is listening elsewhere, so check systemctl status myapp. Missing CSS in Django nearly always means static files were not collected or the Nginx alias path is wrong.
Background jobs, environments and backups
Most real applications eventually need background work: sending email, generating reports, calling slow external APIs. Do not run these inside web requests. Celery with Redis or RabbitMQ is the long-standing choice for Django and Flask, and simpler task queues exist too. Run the worker as a second systemd service using the same virtual environment and the same environment file, so both processes see identical settings.
Keep separate virtual environments for separate projects, and recreate the environment from requirements.txt when you upgrade Python, instead of reusing an old one. Back up the database and the uploads folder on a schedule, and test restoring them. Python releases reach end of life after several years, so note which version you run and plan upgrades. For general advice on protecting your server, see the security page, and read our other server guides in the knowledge base.
When something breaks, work from the outside in: check Nginx's error log first, then journalctl -u myapp for Gunicorn and Python tracebacks, then the database log. A traceback in the journal almost always names the file and line responsible, and the error page you see in the browser is much less informative than that.
Python hosting on Zebra Hosting
This stack needs root-level control of services, which suits a Zebra Hosting VPS. If you are moving an existing app from another provider, the team can help with free migration.