How to deploy a Django app on a VPS with Gunicorn and nginx
To deploy a Django app on a VPS, run it with a production server, put a reverse proxy in front, connect a database and configure HTTPS. This guide uses Ubuntu 24.04 LTS, Django 5.2, PostgreSQL, Gunicorn, systemd and nginx. Adapt the paths and module name to your project.
You need a VPS with SSH and sudo access, a domain pointed at it, a working Django repository and a dependency lock or pinned requirements.txt. The example uses example.com, /srv/myapp and myproject.wsgi:application as placeholders. It covers a WSGI application; use an ASGI setup if your app requires it. References checked on 9 September 2026.
1. Prepare the server and application user
Install the base packages on the new Ubuntu server:
sudo apt update
sudo apt install -y python3-venv python3-dev build-essential libpq-dev postgresql nginx git
sudo adduser --system --group --home /srv/myapp django
sudo install -d -o django -g django /srv/myappThe django user runs the application without root privileges. Keep administrative SSH access with your normal account. Allow your SSH connection in the firewall before enabling firewall rules, and permit inbound HTTP and HTTPS. Keep PostgreSQL reachable only where the app needs it.
2. Create PostgreSQL credentials
Create a database role with a password you choose at the prompt, then create its database:
sudo -u postgres createuser --pwprompt myapp
sudo -u postgres createdb --owner=myapp myappThe application will connect to 127.0.0.1, which uses password authentication under the usual Ubuntu PostgreSQL setup. Confirm your pg_hba.conf if the connection fails. Do not expose the database port publicly to solve a local authentication problem.
3. Install the application and dependencies
Replace the example repository URL. For a private repository, arrange read access without putting a token into the URL or committed files.
sudo -u django git clone https://github.com/your-org/your-app.git /srv/myapp/app
sudo -u django python3 -m venv /srv/myapp/venv
sudo -u django /srv/myapp/venv/bin/pip install -r /srv/myapp/app/requirements.txtYour requirements must include the Django version, Gunicorn and the PostgreSQL driver your project uses. Use the project's existing package manager if it has a different lockfile. Confirm that the settings module and WSGI import path match your repository.
4. Load production settings explicitly
Create /etc/myapp.env using your editor. Store real values there, not in Git. This example assumes your settings read these names:
DJANGO_SECRET_KEY='replace-with-a-long-random-secret'
DJANGO_ALLOWED_HOSTS='example.com'
DJANGO_CSRF_TRUSTED_ORIGINS='https://example.com'
DB_NAME='myapp'
DB_USER='myapp'
DB_PASSWORD='replace-with-the-password-you-set'
DB_HOST='127.0.0.1'
DB_PORT='5432'Use simple quoted values compatible with both a shell and systemd's environment-file syntax. Protect the file:
sudo chown root:django /etc/myapp.env
sudo chmod 640 /etc/myapp.envIn the production settings, configure the matching reads. This fragment assumes BASE_DIR already exists:
import os
DEBUG = False
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
ALLOWED_HOSTS = os.environ['DJANGO_ALLOWED_HOSTS'].split(',')
CSRF_TRUSTED_ORIGINS = os.environ['DJANGO_CSRF_TRUSTED_ORIGINS'].split(',')
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ['DB_NAME'],
'USER': os.environ['DB_USER'],
'PASSWORD': os.environ['DB_PASSWORD'],
'HOST': os.environ['DB_HOST'],
'PORT': os.environ['DB_PORT'],
}
}
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'Keep uploaded media separate from collected static assets. Choose durable storage, a backup policy and access controls appropriate for the uploaded content. See Django's deployment checklist.
5. Check settings and prepare the database
Run management commands as the app user with the same environment as the service:
sudo -u django bash -c 'set -a; . /etc/myapp.env; set +a; cd /srv/myapp/app; /srv/myapp/venv/bin/python manage.py check --deploy'
sudo -u django bash -c 'set -a; . /etc/myapp.env; set +a; cd /srv/myapp/app; /srv/myapp/venv/bin/python manage.py migrate --noinput'
sudo -u django bash -c 'set -a; . /etc/myapp.env; set +a; cd /srv/myapp/app; /srv/myapp/venv/bin/python manage.py collectstatic --noinput'Review the deployment warnings. HTTPS-related settings come after the proxy has a working certificate. For an existing production database, take and test a backup before applying schema changes. Run migrations once as part of the release, rather than in each Gunicorn worker.
6. Run Gunicorn under systemd
Create /etc/systemd/system/myapp.service:
[Unit]
Description=Django application
After=network.target postgresql.service
[Service]
User=django
Group=django
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp.env
ExecStart=/srv/myapp/venv/bin/gunicorn myproject.wsgi:application --bind 127.0.0.1:8001 --workers 2 --access-logfile - --error-logfile -
Restart=on-failure
RestartSec=5
PrivateTmp=true
NoNewPrivileges=true
[Install]
WantedBy=multi-user.targetThe two-worker value is an initial example, not a sizing formula. Measure memory, database connections and response time before increasing it. Gunicorn binds to loopback so visitors reach the app through nginx.
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp --no-pager
curl -I -H 'Host: example.com' http://127.0.0.1:8001/Use journalctl -u myapp for process errors. An import failure usually points to the module path, working directory or dependency installation. See Django's WSGI guidance and Gunicorn.
7. Put nginx in front
Create /etc/nginx/sites-available/myapp:
server {
listen 80;
server_name example.com;
location /static/ {
alias /srv/myapp/app/staticfiles/;
}
location / {
proxy_pass http://127.0.0.1:8001;
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;
}
}Enable the new site once, test the configuration and reload:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo nginx -t
sudo systemctl reload nginxConfirm nginx can read the collected static files and traverse their parent directories. Keep any default site configuration consistent with your other hosted domains; do not remove unrelated sites. The nginx guide explains reverse-proxy and static-file configuration.
8. Add HTTPS and recheck Django
Install a certificate using your chosen ACME client and its current instructions. With Certbot installed for nginx, the certificate request for this example is:
sudo certbot --nginx -d example.com
sudo certbot renew --dry-runUse the Certbot instructions for installation on your OS. Verify domain DNS, HTTP reachability and renewal before considering TLS complete.
Once HTTPS works, set these Django options:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = TrueTrust the forwarded-protocol header only because nginx controls it and Gunicorn is not directly exposed. Restart the service, rerun check --deploy, and test login and a form submission over HTTPS. Review HSTS separately after verifying HTTPS on every affected host.
9. Make future releases repeatable
For each release, choose a reviewed revision, install its locked dependencies, apply any compatible migration, collect static files and restart the service. Test the live application and watch errors after the change.
Keep the previous revision available, but remember that reverting code may not undo a database migration. Document the recovery path for schema changes and test restoring data into a separate database.
If the app uses Celery, give it a separate service definition, command and broker configuration. Do not run the worker as a background shell command inside the Gunicorn service.
When to choose managed hosting instead
A VPS is reasonable when you want server control and can maintain it. If you want to hand off more host operations, compare Python app hosting options. On Lizard, the application can use Managed Postgres and a separate worker, while you keep control of its code and configuration.
The deployment documentation covers that route. You still need correct Django settings, a release process and a verified data-recovery plan.
FAQ
Why do I get a 502 error? Check whether Gunicorn is running and listening on 127.0.0.1:8001, then inspect its logs and nginx's error log. A reverse proxy cannot fix a process that fails to start.
Why are static files missing? Check STATIC_ROOT, the output of collectstatic, nginx's alias path and file permissions. Uploaded media requires a separate setup.
Can I use SQLite instead of PostgreSQL? Some applications can, but assess concurrency, persistence and backup requirements. Do not change the database engine without testing your application's workload.
Is a successful deployment check enough? No. Test a real user flow, database writes, file access and recovery. Configuration checks catch only part of production behaviour.
Build with AI. Ship with Lizard.
You don't need a platform team to go live. Your whole cloud, one CLI command away.
No credit card required