NODE.JS
July 9, 2026

How to Deploy a Django Application on a VPS

7 min read
Author
CloudStick Team
Server Infrastructure
Share this article
How to Deploy a Django Application on a VPS
CloudStick
Django in Production

Lock down settings before anything else

Three settings decide whether your Django deploy is production or an incident report: DEBUG, ALLOWED_HOSTS, and SECRET_KEY. DEBUG=True on a public server leaks stack traces, settings values, and SQL to anyone who triggers an error page. ALLOWED_HOSTS left as an empty list means Django refuses every request the moment DEBUG goes off. And a SECRET_KEY committed to the repository means anyone who can read the code can forge session cookies and password-reset tokens.

The fix for all three is the same habit: pull them from the environment, never from the file. Keep secrets in a .env file the app user owns (or export them in the systemd unit, as below), and read them in settings.py:

# settings.py
import os
DEBUG = os.environ.get('DJANGO_DEBUG', '') == '1'
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
ALLOWED_HOSTS = ['example.com', 'www.example.com']
STATIC_ROOT = '/home/appuser/apps/myproject/staticfiles'
MEDIA_ROOT = '/home/appuser/apps/myproject/media'

Generate a fresh production key with python3 -c "import secrets; print(secrets.token_urlsafe(50))" — do not reuse the one from development, because it has been sitting in your git history since the first commit.

Set up Python, the database, and migrations

Ubuntu 24.04 ships Python 3.12, so the runtime is already there — you only add the venv module and build your environment inside the project directory. SQLite is fine for a hobby project, but anything with concurrent writes wants PostgreSQL or MariaDB:

$ sudo apt update && sudo apt install -y python3-venv python3-pip
$ cd /home/appuser/apps/myproject
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install django gunicorn psycopg2-binary
(venv) $ python manage.py migrate
(venv) $ python manage.py createsuperuser

Point DATABASES in settings.py at a database and user created for this app alone — never the root account. On a CloudStick-managed server you can create the database, the user, and its scoped privileges from the visual Database Manager in a few clicks instead of typing GRANT statements in the mysql shell, which removes the most common copy-paste privilege mistake. Run migrate once the credentials are in the environment and confirm it exits cleanly before going further.

Run Gunicorn under systemd

Django's runserver is a development convenience, not a server. In production, Gunicorn runs your WSGI application and systemd keeps Gunicorn alive — across crashes and across reboots:

# /etc/systemd/system/myproject.service
[Unit]
Description=Gunicorn for myproject
After=network.target
[Service]
User=appuser
WorkingDirectory=/home/appuser/apps/myproject
Environment=DJANGO_SECRET_KEY=your-generated-key
ExecStart=/home/appuser/apps/myproject/venv/bin/gunicorn \
--workers 3 --bind unix:/run/myproject.sock \
myproject.wsgi:application
Restart=always
[Install]
WantedBy=multi-user.target

Enable and start it with sudo systemctl enable --now myproject, then check systemctl status myproject. Three workers is the classic starting point for a small VPS (the 2×CPU+1 rule for a single core); the Unix socket avoids burning a TCP port and makes it impossible to reach Gunicorn from outside the machine. Logs land in the journal — journalctl -u myproject -f is your tail.

TIP

After every deploy that changes Python code, run sudo systemctl restart myproject — Gunicorn workers hold the old code in memory until restarted. Bake it into your deploy script so it can never be forgotten, right after migrate and collectstatic.

Serve static and media files with Nginx

Django does not serve static files in production — with DEBUG off it simply won't, and that is by design. CSS, JavaScript, and admin assets get collected into one directory that Nginx serves directly; user uploads live in MEDIA_ROOT and get the same treatment:

(venv) $ python manage.py collectstatic --noinput
165 static files copied to
'/home/appuser/apps/myproject/staticfiles'.

Every request Nginx answers from disk is a request that never wakes a Gunicorn worker. On a modest VPS this is the single biggest capacity win in the whole setup: an app that struggles at fifty concurrent users with Django serving assets will cruise once Nginx takes them over.

The Nginx server block

One server block ties it together: static and media served from disk, everything else proxied to the Gunicorn socket with the headers Django needs to build correct URLs and see real client IPs:

server {
listen 80;
server_name example.com www.example.com;
client_max_body_size 25m;
location /static/ {
alias /home/appuser/apps/myproject/staticfiles/;
expires 30d;
}
location /media/ {
alias /home/appuser/apps/myproject/media/;
}
location / {
proxy_pass http://unix:/run/myproject.sock;
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, then add HTTPS — certbot --nginx will rewrite the block for TLS, or on a CloudStick server the free Let's Encrypt certificate is issued and auto-renewed from the dashboard when you create the site, with nginx-cs handling the vhost so you never hand-edit this file at all. Once traffic arrives over HTTPS, set SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') in settings so Django trusts the proxy header.

Verify with the deploy checklist

Django grades your deployment for you. Run python manage.py check --deploy in the venv and it audits the settings that matter — DEBUG, HSTS, secure cookies, referrer policy — and prints a warning for each one it dislikes. Work the list until what remains is only what you consciously accepted.

From here the routine is stable: git pull, pip install -r requirements.txt, migrate, collectstatic --noinput, systemctl restart myproject — five commands worth putting in a deploy.sh today. Watch journalctl for the first real traffic, confirm the admin loads over HTTPS with static files styled (an unstyled admin means collectstatic or the alias path is wrong), and take a database backup before your first schema change in production. That is the whole machine: settings from the environment, Gunicorn under systemd, Nginx in front, and a checklist that keeps you honest.

Leave a comment
Full Name
Email Address
Message
Contents