DEV Community

dpm_bush
dpm_bush

Posted on Originally published at sshflow.com

Deploying Django with Gunicorn and Nginx: A Practical Ubuntu Setup

A Django app needs more than a development server to handle production traffic. A common setup gives each layer one job: Nginx accepts web requests, Gunicorn runs Django’s WSGI application, and systemd manages Gunicorn as a service.

The request path looks like this:

Browser → Nginx → Gunicorn → Django
Enter fullscreen mode Exit fullscreen mode

This example uses Ubuntu 24.04, a project at /srv/myproject, a virtual environment at /srv/myproject/.venv, and the Django package name myproject. Change those values to match your deployment. It assumes a WSGI application; ASGI features such as WebSockets need an ASGI server and a suitable proxy setup instead.

Keep Gunicorn private

Nginx is the public-facing web server. Gunicorn only needs to accept connections from Nginx on the same machine, so this setup binds it to 127.0.0.1:8000, not 0.0.0.0.

Install the packages and create an application account and directory:

sudo apt update
sudo apt install python3-venv python3-pip nginx
sudo adduser --system --group --home /srv/myproject deploy
sudo install -d -o deploy -g deploy /srv/myproject
Enter fullscreen mode Exit fullscreen mode

Place your project files under /srv/myproject, then create its environment and install dependencies:

sudo -u deploy python3 -m venv /srv/myproject/.venv
sudo -u deploy /srv/myproject/.venv/bin/pip install -r /srv/myproject/requirements.txt
sudo -u deploy /srv/myproject/.venv/bin/pip install gunicorn
Enter fullscreen mode Exit fullscreen mode

If Gunicorn is already in your requirements file, you don’t need to install it a second time. The important part is that Gunicorn is installed in the same virtual environment the service will use.

For a standard Django project created with django-admin startproject myproject, the WSGI target is usually myproject.wsgi:application. Use the package name that actually contains your wsgi.py file.

Before exposing the app, check its production settings. Set DEBUG = False, configure ALLOWED_HOSTS for the domains you serve, and load SECRET_KEY from a protected environment or secrets mechanism rather than committing it to source control. Set STATIC_ROOT to the directory where collected assets will live, for example:

DEBUG = False
ALLOWED_HOSTS = ["example.com", "www.example.com"]
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
Enter fullscreen mode Exit fullscreen mode

Let systemd run Gunicorn

Create /etc/systemd/system/gunicorn.service:

[Unit]
Description=Gunicorn for the Django application
After=network.target

[Service]
User=deploy
Group=deploy
WorkingDirectory=/srv/myproject
EnvironmentFile=/etc/myproject.env
ExecStart=/srv/myproject/.venv/bin/gunicorn \
    --workers 3 \
    --bind 127.0.0.1:8000 \
    myproject.wsgi:application
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

The three-worker setting is an example starting point, not a universal recommendation. Adjust it for your workload and available memory. The environment file must exist and be readable by the service account. For example, create it with restricted permissions and edit it:

sudo install -o root -g deploy -m 0640 /dev/null /etc/myproject.env
sudoedit /etc/myproject.env
Enter fullscreen mode Exit fullscreen mode

Add the secret using the variable name your Django settings read. Then load the unit and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn
sudo systemctl status gunicorn --no-pager
Enter fullscreen mode Exit fullscreen mode

If the service fails, check its status and recent journal output before changing settings. This guide to reading systemctl status explains what the service state and output can tell you.

Configure Nginx to proxy requests

Create /etc/nginx/sites-available/myproject with a server block that uses the same upstream address as Gunicorn:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    location /static/ {
        alias /srv/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;
    }
}
Enter fullscreen mode Exit fullscreen mode

The static-file alias should match Django’s STATIC_ROOT; Nginx needs permission to read the collected files and traverse their parent directories. Avoid giving it access to application secrets or private files unnecessarily.

Enable the site and test the configuration before reloading Nginx:

sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/myproject
sudo nginx -t
sudo systemctl reload nginx
Enter fullscreen mode Exit fullscreen mode

Also check that the domain points to the server and that inbound HTTP traffic is allowed by the host and provider firewalls. Once HTTP works, configure TLS before treating the site as ready for users.

Verify each layer separately

Run Django’s deployment check, apply reviewed database migrations, and collect static files as the application account:

cd /srv/myproject
sudo -u deploy /srv/myproject/.venv/bin/python manage.py check --deploy
sudo -u deploy /srv/myproject/.venv/bin/python manage.py migrate
sudo -u deploy /srv/myproject/.venv/bin/python manage.py collectstatic --noinput
Enter fullscreen mode Exit fullscreen mode

Then restart Gunicorn and test the direct application path and the Nginx path separately:

sudo systemctl restart gunicorn
sudo systemctl status gunicorn --no-pager
curl -i -H "Host: example.com" http://127.0.0.1:8000/
curl -I http://example.com/
Enter fullscreen mode Exit fullscreen mode

The first request bypasses Nginx and tests Gunicorn and Django. The second tests the public HTTP route through Nginx. Use a URL your app is expected to serve; a failure at one layer doesn’t automatically mean the other is broken.

Troubleshoot from the failing layer

If Gunicorn won’t start, inspect its service status and journal. Confirm the virtualenv path, working directory, WSGI target, dependencies, and environment file.

If the direct Gunicorn request works but the public request returns 502, check that Gunicorn is listening on 127.0.0.1:8000 and that Nginx’s proxy_pass uses that exact address. Nginx logs can help identify the upstream failure; see this step-by-step Nginx 502 troubleshooting guide.

For missing static assets, confirm collectstatic completed, the file exists under STATIC_ROOT, and the Nginx alias matches that path. For a Django DisallowedHost error, check ALLOWED_HOSTS and Nginx’s server_name.

The core deployment check is consistency: systemd must run the intended Gunicorn executable and WSGI app, Nginx must proxy to the address Gunicorn actually uses, and its static alias must match Django’s collected-file directory.

I originally published a more detailed version of this guide on the SSHFlow blog.

I'm also building SSHFlow — an SSH client where every server gets its own workspace for terminals, SFTP, code, and databases.

Top comments (0)