Deploying Django with Gunicorn and Nginx on Ubuntu 24.04 gives each layer one clear responsibility: Django handles application logic, Gunicorn runs the WSGI application, systemd manages the Gunicorn process, and Nginx handles public HTTP/HTTPS traffic and static files. Raff users can use this pattern on a Linux VM without exposing Django's development server or a raw Gunicorn port to the internet.
Django's production checklist explicitly says manage.py runserver is not designed for production and recommends a production WSGI or ASGI server instead. Gunicorn's deployment documentation recommends placing Gunicorn behind a proxy such as Nginx. The worker-count formula often quoted for Gunicorn is only a starting point; the correct count depends on CPU, memory, database connections, request behavior, and measured load.
This tutorial creates a small Django app, keeps SECRET_KEY outside source control, runs Gunicorn as a non-root systemd service over a Unix socket, serves collected static files with Nginx, keeps the application port private, and finishes with HTTPS and Django's deployment checks.
Prerequisites:
- A Raff Linux VM running Ubuntu 24.04
- SSH access with a non-root sudo user
- A domain such as
example.compointing to the VM - TCP ports 80 and 443 available for Nginx
- No requirement to expose Gunicorn directly to the public internet
Step 1 — Install Python and Nginx and Create a Django Service User
Update package metadata and install the packages used by this deployment:
sudo apt update sudo apt install -y python3-venv nginx curl
Create a dedicated non-login user for the Django application:
sudo useradd --system \ --home /srv/mysite \ --create-home \ --shell /usr/sbin/nologin \ djangoapp
Create the application directory and give the service user ownership:
sudo mkdir -p /srv/mysite/app sudo chown -R djangoapp:djangoapp /srv/mysite
Confirm Python and Nginx are available:
python3 --version nginx -v systemctl is-active nginx
Do not depend on an exact Python patch version in automation; Ubuntu security updates can change it within the supported 3.12 series.
Verify: Python should run, Nginx should report a version, and systemctl is-active nginx should return active.
Step 2 — Create a Virtual Environment and Install Django and Gunicorn
Create a virtual environment owned by the application user:
sudo -u djangoapp python3 -m venv /srv/mysite/venv
Upgrade pip inside the virtual environment, not the system Python:
sudo -u djangoapp /srv/mysite/venv/bin/python -m pip install --upgrade pip
For this demo, install Django and Gunicorn:
sudo -u djangoapp /srv/mysite/venv/bin/python -m pip install django gunicorn
For an existing application, install its reviewed dependency file instead of blindly replacing versions:
sudo -u djangoapp /srv/mysite/venv/bin/python -m pip install -r /srv/mysite/app/requirements.txt
Create a demo project:
cd /srv/mysite/app sudo -u djangoapp /srv/mysite/venv/bin/django-admin startproject config .
Add a small health endpoint to config/urls.py:
from django.contrib import admin from django.http import JsonResponse from django.urls import path def health(_request): return JsonResponse({"status": "ok"}) urlpatterns = [ path("admin/", admin.site.urls), path("health/", health), ]
Confirm the packages and WSGI module import correctly:
sudo -u djangoapp /srv/mysite/venv/bin/python -c 'import django, gunicorn, config.wsgi; print(django.get_version())'
Verify: Django should print a version and the command should exit without an import error.
Step 3 — Store the Django Secret Outside the Project
Django's deployment checklist recommends keeping SECRET_KEY secret and out of source control. Create a root-managed environment file that the service user can read:
sudo install -m 640 -o root -g djangoapp /dev/null /etc/mysite.env
Generate a high-entropy secret without printing it to your terminal history:
sudo sh -c 'printf "DJANGO_SECRET_KEY=%s\n" "$(python3 -c "import secrets; print(secrets.token_urlsafe(50))")" > /etc/mysite.env' sudo chown root:djangoapp /etc/mysite.env sudo chmod 640 /etc/mysite.env
Confirm the service account can read the file without displaying the secret:
sudo -u djangoapp test -r /etc/mysite.env && echo readable
Do not commit /etc/mysite.env to Git or copy its value into the systemd unit.
Verify: The command should print readable, and stat -c '%U %G %a' /etc/mysite.env should show root djangoapp 640.
Step 4 — Configure Django for Production
Edit /srv/mysite/app/config/settings.py and add import os near the top if it is not already present. Use production settings like these:
import os SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] DEBUG = False ALLOWED_HOSTS = ["example.com", "www.example.com"] STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "staticfiles"
Do not use ALLOWED_HOSTS = ["*"] as a shortcut. Django recommends validating the expected hostnames in production.
For an existing project, also review database credentials, email settings, caches, uploaded media, logging, and any environment-specific settings. Database passwords should be protected like SECRET_KEY rather than committed to the repository.
Load the environment and run Django's basic system check:
sudo -u djangoapp bash -c ' set -a . /etc/mysite.env set +a cd /srv/mysite/app ../venv/bin/python manage.py check '
Verify: manage.py check should finish without configuration errors and DEBUG should be False in the production settings.
Step 5 — Run Migrations and Collect Django Static Files
Apply migrations and collect static assets under STATIC_ROOT:
sudo -u djangoapp bash -c ' set -a . /etc/mysite.env set +a cd /srv/mysite/app ../venv/bin/python manage.py migrate ../venv/bin/python manage.py collectstatic --noinput '
Django's production documentation expects static files to be collected and then served by a web server or another static-file service rather than by the development server.
Check that the directory exists and contains files:
sudo -u djangoapp find /srv/mysite/app/staticfiles -maxdepth 2 -type f | head
For a real application, back up the database before production migrations and use your application's tested release procedure instead of treating every migration as risk-free.
Verify: Migrations should complete successfully and staticfiles/ should contain collected assets.
Step 6 — Test Django with Gunicorn on Localhost
Before creating a service, test Gunicorn locally. Bind only to loopback so the raw WSGI server is not exposed publicly:
sudo -u djangoapp bash -c ' set -a . /etc/mysite.env set +a cd /srv/mysite/app ../venv/bin/gunicorn \ --bind 127.0.0.1:8000 \ --workers 2 \ config.wsgi:application '
In a second SSH session, send the expected host header:
curl -H 'Host: example.com' http://127.0.0.1:8000/health/
Expected output:
{"status": "ok"}
Django's runserver is a development server; the reason to move to Gunicorn is not that runserver is always single-threaded, but that Django explicitly says it is not designed for production.
Stop the foreground Gunicorn test with Ctrl+C after verification.
Verify: Gunicorn should return the health response on 127.0.0.1:8000, and no public firewall rule should exist for port 8000.
Step 7 — Run Gunicorn as a systemd Service over a Unix Socket
Create /etc/systemd/system/mysite-gunicorn.service:
[Unit] Description=Gunicorn for Django mysite After=network.target [Service] Type=notify NotifyAccess=main User=djangoapp Group=www-data WorkingDirectory=/srv/mysite/app EnvironmentFile=/etc/mysite.env RuntimeDirectory=mysite RuntimeDirectoryMode=0750 UMask=0007 ExecStart=/srv/mysite/venv/bin/gunicorn \ --workers 2 \ --access-logfile - \ --error-logfile - \ --bind unix:/run/mysite/gunicorn.sock \ config.wsgi:application ExecReload=/bin/kill -s HUP $MAINPID KillMode=mixed TimeoutStopSec=30 Restart=on-failure RestartSec=5 PrivateTmp=true NoNewPrivileges=true [Install] WantedBy=multi-user.target
Reload systemd and start the service:
sudo systemctl daemon-reload sudo systemctl enable --now mysite-gunicorn
Check the service and socket:
systemctl is-active mysite-gunicorn sudo ls -l /run/mysite/gunicorn.sock
Test the socket as the Nginx user:
sudo -u www-data curl \ --unix-socket /run/mysite/gunicorn.sock \ -H 'Host: example.com' \ http://localhost/health/
Gunicorn's current deployment documentation supports systemd-managed services and Unix sockets. Type=notify lets Gunicorn report readiness to systemd.
Verify: The service should be active, the socket should exist under /run/mysite/, and the www-data curl command should return {"status": "ok"}.
Step 8 — Configure Nginx for Django and Gunicorn
Create /etc/nginx/sites-available/mysite:
server { listen 80; listen [::]:80; server_name example.com www.example.com; client_max_body_size 10m; location /static/ { alias /srv/mysite/app/staticfiles/; access_log off; expires 7d; add_header Cache-Control "public"; } location / { proxy_pass http://unix:/run/mysite/gunicorn.sock; proxy_http_version 1.1; 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; proxy_redirect off; } }
This tutorial does not add WebSocket upgrade headers because standard Django through Gunicorn's WSGI worker is an HTTP WSGI deployment. If your application requires WebSockets or other ASGI behavior, use an ASGI deployment rather than copying WSGI settings blindly.
Enable the site and remove the Ubuntu default only if it is not serving anything else:
sudo ln -sfn /etc/nginx/sites-available/mysite /etc/nginx/sites-enabled/mysite sudo rm -f /etc/nginx/sites-enabled/default sudo nginx -t sudo systemctl reload nginx
On a dedicated single-site VM, you can also configure a separate default server that rejects unknown hostnames instead of forwarding them to Django.
Verify: sudo nginx -t should succeed and Nginx should reload without errors.
Step 9 — Set Static-File Permissions and Keep Gunicorn Private
Nginx needs read and traverse permission for the collected static directory, but it does not need write access to your project:
sudo chmod 755 /srv/mysite /srv/mysite/app sudo find /srv/mysite/app/staticfiles -type d -exec chmod 755 {} + sudo find /srv/mysite/app/staticfiles -type f -exec chmod 644 {} +
If UFW is active, allow SSH before making firewall changes, then allow Nginx:
sudo ufw allow OpenSSH sudo ufw allow 'Nginx Full' sudo ufw status numbered
Do not add a public rule for port 8000. The persistent Gunicorn service uses a Unix socket and Nginx is the public edge.
If your project has user-uploaded media, treat uploads as untrusted data and configure a separate media path carefully. Do not let Nginx execute uploaded content.
Verify: UFW should allow the intended SSH and web ports, and there should be no public Gunicorn port.
