← Writing
Tech / writing June 4, 2026 · 2 min read

How to Deploy Django on a VPS with Gunicorn, Nginx and a Unix Socket

I deploy a lot of Django apps to plain Linux VPS boxes, and over time I’ve settled on one layout I use every single time. It’s boring, repeatable, and survives reboots — which is exactly what you want in production. Here’s the whole thing.

The wrapper layout

I never put the virtualenv inside the repo, and I never bind gunicorn to a TCP port. Instead, each app gets a wrapper directory holding three siblings:

myproject/
├── run/            # gunicorn.sock lives here
├── .venv/          # virtualenv — sibling of the code, never inside it
└── app/            # the repo: manage.py, settings, .env

Keeping .venv outside the code means I can nuke and rebuild the environment without touching a single tracked file. (Moving a venv breaks it — the shebangs in .venv/bin/* are absolute paths — so always recreate it at the new location, never mv it.)

Gunicorn over a unix socket

For an app and nginx on the same box, a unix socket beats a TCP port: no port bookkeeping, and it’s not exposed to the network. The systemd unit:

[Unit]
Description=myproject (Django/gunicorn)
After=network.target
[Service]
User=root
Group=www-data
WorkingDirectory=/home/ubuntu/myproject/app
EnvironmentFile=/home/ubuntu/myproject/app/.env
ExecStart=/home/ubuntu/myproject/.venv/bin/gunicorn config.wsgi \
          --bind unix:/home/ubuntu/myproject/run/gunicorn.sock --umask 007 --workers 3 --timeout 60
Restart=always
[Install]
WantedBy=multi-user.target

Group=www-data plus --umask 007 makes the socket 0770 root:www-data, so nginx (running as www-data) can connect to it but nothing else can.

Nginx in front

server {
    listen 443 ssl;
    server_name example.com www.example.com;
    location /static/ { alias /home/ubuntu/myproject/app/staticfiles/; expires 30d; }
    location /media/  { alias /home/ubuntu/myproject/app/media/;       expires 30d; }
    location / {
        proxy_pass http://unix:/home/ubuntu/myproject/run/gunicorn.sock;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
    }
}

If you’re behind Cloudflare, set SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') in settings so Django knows the original request was HTTPS — otherwise CSRF and the admin will fight you.

The update flow

  1. Push code to the box (git pull or rsync).
  2. .venv/bin/pip install -r requirements.txt
  3. manage.py migrate
  4. manage.py collectstatic --noinput
  5. systemctl restart myproject

Gotchas I’ve hit

  • 502 right after switching to the socket — that’s the graceful-reload drain window; it clears once the reload finishes. Re-test after a second.
  • Permission denied on the socket — the parent directories must be traversable by www-data (o+x).
  • HTTPS 403 on forms — missing CSRF_TRUSTED_ORIGINS or the proxy SSL header.

FAQ

Should I use a unix socket or a TCP port? For nginx + gunicorn on the same machine, a socket — it’s faster and not network-exposed. Use TCP only when the proxy is on a different host.

Where should the virtualenv live? Outside the repo, beside it. That keeps deploys clean and the env disposable.

Do I need certbot behind Cloudflare? No — if your DNS is proxied, use a Cloudflare Origin cert (or a self-signed cert with SSL mode Full). HTTP-01 challenges break on proxied DNS.

More tech / writing