MHOSTMHOST

Deploying Django on a VPS

In this guide you'll deploy a Django project on your VPS in production mode: a virtual environment, Gunicorn as a systemd service, and Nginx as a reverse proxy serving static files — plus the correct DEBUG and ALLOWED_HOSTS settings.

What you'll need

  • A VPS running Ubuntu 22.04/24.04, Debian 11/12, or AlmaLinux/Rocky Linux 8/9, with SSH access as root or a sudo user.
  • A domain pointed at the server's IP (a bare IP address works too, if you don't have a domain yet).
  • Your own Django project — developed locally or already in a git repository, with a requirements.txt dependency file.
  • Basic comfort working in a terminal over SSH.
Tip: if the exact stack doesn't matter to you, VMmanager 6 has a ready-made Django recipe — a Django + uWSGI + Nginx environment in one click (for logging into the panel, see "How to log in to VMmanager 6"; for the full catalog, see "Ready-made VMmanager 6 recipes"). Below is the manual setup with Gunicorn and systemd instead of uWSGI — it gives you more control over the process and the service.

Step 1. Install system packages

Connect to the server over SSH and install Python with the virtual environment tooling, plus Nginx:

bash
# Ubuntu / Debian
sudo apt update
sudo apt install -y python3-venv python3-pip nginx
bash
# AlmaLinux / Rocky
sudo dnf install -y python3 python3-pip nginx
Tip: check the version — python3 --version. Modern Django versions need Python 3.10 or newer. On AlmaLinux/Rocky 8 the system python3 is version 3.6, which isn't enough: install a newer version (for example, sudo dnf install -y python3.11) and in step 2 create the environment with python3.11 -m venv venv instead of python3 -m venv venv. On Ubuntu/Debian and AlmaLinux/Rocky 9 the system Python is usually new enough already.
Verify: check the exact minimum Python version for your Django version in its release notes on docs.djangoproject.com — the requirement creeps up over time.

Step 2. Create a user, copy the project, and set up a virtual environment

Running a web app as root isn't a good idea. Create a dedicated system user for the project:

bash
# Ubuntu / Debian
sudo adduser django
bash
# AlmaLinux / Rocky
sudo useradd -m -s /bin/bash django
sudo passwd django
Tip: if you already have a suitable user for the project (for example, the one you use to log in over SSH), use it instead — just substitute its name for django in every command and file below.

Switch to that user and deploy the project into its home directory — so that manage.py ends up directly in /home/django/myproject:

bash
sudo -iu django
git clone https://github.com/yourname/myproject.git myproject
cd myproject
Tip: if the project isn't in a git repository yet, just copy the files to the server with scp or rsync, into the same /home/django/myproject directory.

Create a virtual environment and activate it:

bash
python3 -m venv venv
source venv/bin/activate

Install the project's dependencies and Gunicorn:

bash
pip install --upgrade pip
pip install -r requirements.txt
pip install gunicorn
Tip: if Gunicorn is already listed in requirements.txt, installing it again won't change anything. If your project uses a database, don't forget to also run python manage.py migrate — setting up the database itself is outside the scope of this article.

Step 3. Configure DEBUG and ALLOWED_HOSTS

Django is configured for development by default, not production. Before opening the site to the outside world, edit myproject/settings.py (replace myproject with your own project's settings package name):

python
# myproject/settings.py
DEBUG = False

ALLOWED_HOSTS = ["example.com", "www.example.com", "203.0.113.10"]
Important: you must never leave DEBUG = True in production — on an error, Django shows the visitor a full traceback along with excerpts of your source code, local variables, settings, and the list of installed libraries.

With DEBUG = False, Django doesn't respond to requests at all if ALLOWED_HOSTS is empty — this list protects the site from requests with a spoofed Host header. Add to it:

  • the domain(s) the site will be served on — for example, example.com and www.example.com;
  • the server's IP address, if you need it;
  • subdomains can be allowed with a single wildcard by putting a leading dot on the value: .example.com matches both example.com and any of its subdomains;
  • a value of * (allow any Host) is technically valid, but then you have to implement your own validation of the Host header — for a normal site, use a specific list of domains/IPs instead.

Step 4. Test-run the project with Gunicorn

While you're still in the virtual environment, run Gunicorn by hand — it's the fastest way to confirm the project comes up at all before wrapping it in systemd:

bash
gunicorn --bind 127.0.0.1:8000 myproject.wsgi:application

In a second SSH session, check the response (replace example.com with the value you put in ALLOWED_HOSTS):

bash
curl -I -H "Host: example.com" http://127.0.0.1:8000/

The expected result is HTTP/1.1 200 OK (or a redirect, if your project is set up that way). Stop Gunicorn with Ctrl+C.

Tip: a DisallowedHost error means the Host header you sent isn't in ALLOWED_HOSTS (step 3). A ModuleNotFoundError: No module named 'myproject' error usually means the command wasn't run from the directory containing manage.py — cd into /home/django/myproject and try again.

Once you're done testing, run deactivate and exit — the remaining steps (systemd, Nginx) are done as root or a sudo user.

Step 5. Set up Gunicorn as a systemd service

As root/sudo, create the unit file /etc/systemd/system/myproject.service:

ini
[Unit]
Description=Gunicorn daemon for myproject
After=network.target

[Service]
User=django
Group=django
WorkingDirectory=/home/django/myproject
ExecStart=/home/django/myproject/venv/bin/gunicorn \
          --workers 3 \
          --bind 127.0.0.1:8000 \
          myproject.wsgi:application
Restart=on-failure

[Install]
WantedBy=multi-user.target
  • User/Group — the project's user from step 2, not root.
  • --workers — the number of Gunicorn worker processes; a good starting point is "(2 × number of cores) + 1" — check the core count with nproc.
  • Gunicorn is launched via its full path inside venv, not the system-wide one — that guarantees the environment with this project's own dependencies is used.

Apply and enable the service:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now myproject
sudo systemctl status myproject
Done: if systemctl status shows active (running), Gunicorn is up and running as a service — it will survive a server reboot and restart itself automatically if it crashes (Restart=on-failure).
Tip: service logs — sudo journalctl -u myproject -f. Handy if the site doesn't come up and you need to quickly see what's wrong.

Step 6. Configure Nginx: reverse proxy and static files

Collect the project's static files. Switch back to the project's user and activate the virtual environment:

bash
sudo -iu django
cd myproject
source venv/bin/activate

Add to myproject/settings.py:

python
# myproject/settings.py
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

and run:

bash
python manage.py collectstatic

Django copies all static files — its own and those of third-party apps, including the admin — into /home/django/myproject/staticfiles/. Nginx will serve this folder directly, without touching Gunicorn at all.

Switch back to root/sudo (deactivate, then exit) and create the site's configuration file — for example, sudo nano /etc/nginx/sites-available/myproject on Ubuntu/Debian, or sudo nano /etc/nginx/conf.d/myproject.conf on AlmaLinux/Rocky — with the following content:

nginx
server {
    listen 80;
    server_name example.com www.example.com;

    location /static/ {
        alias /home/django/myproject/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;
    }
}

Replace server_name with your own domain (or the server's IP) — the same value must be in ALLOWED_HOSTS from step 3.

On Ubuntu/Debian, enable the site with a symlink and remove the default one:

bash
sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default

On AlmaLinux/Rocky there's nothing extra to enable — everything under /etc/nginx/conf.d/ is read automatically.

Test the configuration and reload Nginx:

bash
sudo nginx -t
sudo systemctl reload nginx
sudo systemctl enable --now nginx
Tip: on Ubuntu/Debian, Nginx is already running and enabled right after installation — enable --now is harmless there. On AlmaLinux/Rocky this command is required, or Nginx won't come up after install or after a reboot.
Important: on AlmaLinux/Rocky, SELinux is enabled by default, and by default it blocks Nginx from proxying to port 8000 — you'll get a 502 Bad Gateway instead of your site. Allow this kind of connection: sudo setsebool -P httpd_can_network_connect on.

Step 7. Open the port in the firewall

If the server has a firewall enabled, the site won't be reachable from outside while port 80 is closed.

bash
# ufw (Ubuntu/Debian)
sudo ufw allow 80/tcp
bash
# firewalld (AlmaLinux/Rocky)
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --reload
Important: for more on firewall setup, see the "UFW firewall setup" article.

Step 8. Check the result

Open the domain (or the server's IP) in a browser, or check from the terminal:

bash
curl -I http://example.com/

The expected response is HTTP/1.1 200 OK. If the page has styles or images served from /static/ (for example, the Django admin's styles at /static/admin/), make sure those load too — that confirms Nginx is serving STATIC_ROOT correctly.

Done: the site is served through Nginx, static files are served directly from staticfiles/, and Gunicorn is running in the background under systemd with DEBUG = False and ALLOWED_HOSTS configured.

What's next

  • Add HTTPS (for example, via Let's Encrypt/certbot) — the current Nginx config is already set up to have a listen 443 ssl; block added on top of it.
  • If the server will host several projects, give each one its own directory, virtual environment, systemd unit, and Nginx server block with its own server_name.
  • For other ready-made VPS scenarios, see the "Ready-made VMmanager 6 recipes" article.