SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Deploy Django with Gunicorn and Nginx

Run Django in production on Ubuntu 24.04: uv or venv, Gunicorn on a Unix socket, a systemd unit, Nginx for TLS and static files, plus what breaks first.

What you are building

Deploy Django with Gunicorn and Nginx and you get four moving parts on one VPS: an unprivileged system account that owns the code, Gunicorn running your WSGI (web server gateway interface) application, a systemd unit that keeps Gunicorn alive across crashes and reboots, and Nginx in front on port 443 handling TLS (transport layer security) and static files.

Django's own manage.py runserver is not on that list. It is single threaded, it restarts itself whenever a file changes, and the Django documentation says plainly that it has not gone through security audits. It is a development tool.

This guide is written against Ubuntu 24.04 LTS, which ships Python 3.12 and Nginx 1.24. The settings below use the current STORAGES form, so nothing here is deprecated on the Django 5.2 LTS line (the current long term support release as of August 2026) or on 6.x. If you have not settled on the framework yet, the Django and Flask comparison for a VPS covers that choice.

Why Django needs Gunicorn and Nginx at all

Gunicorn speaks WSGI to your Django code and HTTP to whatever sits in front of it. It does not terminate TLS, and it does not serve files from disk cheaply. Its default synchronous worker reads a whole request before handing it to your view, so a client sending one byte per second holds a worker for as long as it likes. Gunicorn's documentation is direct about the consequence: with the default synchronous workers you must make sure the proxy buffers slow clients, or Gunicorn is open to denial of service.

Nginx covers the four jobs Gunicorn is not built for. It terminates TLS. It buffers slow requests and slow responses. It serves /static/ straight off the disk without waking a Python process. And it is the only process that needs to listen on a public port, because Gunicorn binds a Unix domain socket with no port at all. The individual proxy directives are unpacked in this walkthrough of an Nginx reverse proxy config.

The service account and the Python environment

Create a system account that owns the code and nothing else. --system gives it no password and a nologin shell, so nobody signs in as it.

sudo apt update
sudo apt install -y python3-venv git nginx
sudo adduser --system --group --home /srv/myproject django
sudo -u django git clone https://github.com/example/myproject.git /srv/myproject/app
ls -ld /srv/myproject

Read that last line of output now. Ubuntu's /etc/adduser.conf sets DIR_MODE=0750, so a home directory created this way is not traversable by other accounts, and www-data is another account. The mode of /srv/myproject decides whether Nginx can reach your static files later.

Build the environment as the same account.

sudo -u django python3 -m venv /srv/myproject/venv
sudo -u django /srv/myproject/venv/bin/pip install -r /srv/myproject/app/requirements.txt

Gunicorn belongs inside requirements.txt next to Django, not installed by hand afterwards, because a rebuild on a fresh server will otherwise be missing it.

uv is the faster option and its lock file makes the install repeatable. Install it with the documented one-liner, curl -LsSf https://astral.sh/uv/install.sh | sh, then run uv add gunicorn and uv sync --frozen inside /srv/myproject/app. uv sync creates .venv beside pyproject.toml, so every absolute path below moves from /srv/myproject/venv/bin/ to /srv/myproject/app/.venv/bin/. Which tool to standardise on is the subject of comparing venv, pipx and uv on a server.

Do not reach for sudo pip. Ubuntu 24.04 refuses it anyway: pip outside a virtual environment exits with an externally-managed-environment error, because the system Python belongs to apt and apt will overwrite whatever you put there.

The settings that must change for production

Read the production values from the environment. The same code then runs in both places with no branch on hostname.

# myproject/settings.py
import os

DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
CSRF_TRUSTED_ORIGINS = ["https://" + h for h in ALLOWED_HOSTS]

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

STATIC_URL = "/static/"
STATIC_ROOT = "/srv/myproject/static"
STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"},
}

SECRET_KEY uses os.environ[...] and not .get() on purpose. A missing key then stops the process at import with a KeyError, instead of letting Django boot on a fallback value and quietly invalidate every session cookie the next time that fallback changes.

CSRF_TRUSTED_ORIGINS needs the scheme and not only the host name. Django 4.0 made that mandatory, and a form that posts fine over HTTP and returns a CSRF failure page over HTTPS is the symptom of leaving it out.

STORAGES replaced STATICFILES_STORAGE in Django 4.2, and STATICFILES_STORAGE was removed in 5.1. Prefer it for that reason alone. Setting STORAGES replaces the whole default dictionary rather than merging into it, so keep the default entry or file uploads lose their backend.

Now the environment file. systemd reads it as root, before it drops to the django account, so the secret never has to be readable by anything web facing.

# /etc/myproject/env
DJANGO_SECRET_KEY=paste-the-generated-value-here
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DJANGO_DEBUG=0
sudo install -d -m 755 /etc/myproject
sudo install -m 640 -o root -g django /dev/null /etc/myproject/env
python3 -c 'import secrets; print(secrets.token_urlsafe(64))'
sudo nano /etc/myproject/env

The install line creates the file empty with the right owner and mode in one step, so there is no window where the secret sits in a world readable file. Mode 640 owned root:django is deliberate: systemd can read it, the django account can read it for management commands, and www-data cannot read it at all.

A systemd EnvironmentFile performs no shell expansion, so $HOME stays the literal text $HOME and a # starts a comment. Keep the values plain. Use the key the secrets command printed, because check --deploy reports security.W009 for a key that still carries the django-insecure- prefix startproject wrote.

Every management command from here uses the same shape: become django, source the environment file, run the command.

sudo -u django sh -c 'set -a; . /etc/myproject/env; set +a; exec /srv/myproject/venv/bin/python /srv/myproject/app/manage.py check --deploy'

set -a exports each variable the file assigns, which is what makes them visible to Django. Read every line the check prints. security.W018 means DEBUG is still true. security.W020 means ALLOWED_HOSTS came out empty. Those two are failures. security.W004 about SECURE_HSTS_SECONDS and security.W008 about SECURE_SSL_REDIRECT are the two you can leave for now, because Nginx performs the redirect and HSTS (HTTP strict transport security) is hard to undo once browsers have cached it.

What collectstatic actually does

With DEBUG = True, django.contrib.staticfiles searches every installed app's static/ directory plus every path in STATICFILES_DIRS on each request, and the development server returns what it finds. With DEBUG = False that request handling stops. Django serves no static file in production by design, because a Python process is the wrong thing to be reading CSS off a disk.

collectstatic is the replacement. It walks that same search path once and copies every file it finds into the single directory named by STATIC_ROOT. Afterwards one directory holds your CSS, the Django admin's CSS, and whatever any third party app shipped, all under the paths your {% static %} tags already produce. Nginx serves that one directory and never asks Django anything.

STATIC_URL and the Nginx location have to agree. STATIC_URL = "/static/" makes {% static "css/site.css" %} render as /static/css/site.css, so Nginx needs a location /static/ pointing at STATIC_ROOT. Change one and not the other and every asset 404s while the pages themselves load fine.

Keep STATIC_ROOT outside every directory listed in STATICFILES_DIRS and outside every app's static/ folder. If it sits inside one of them, the next collectstatic reads its own output as input. manage.py collectstatic --dry-run --noinput prints the paths it would copy, so run that once and read them.

sudo -u django sh -c 'set -a; . /etc/myproject/env; set +a; exec /srv/myproject/venv/bin/python /srv/myproject/app/manage.py collectstatic --noinput'
ls -l /srv/myproject/static/admin/css/base.css

The command ends with a count of the files it copied. The ls is the check that matters, because the admin stylesheet is the file whose absence you notice first: an unstyled Django admin login page means either collectstatic did not run or Nginx is not serving STATIC_ROOT.

ManifestStaticFilesStorage renames each collected file to include a hash of its contents and writes a staticfiles.json manifest mapping the plain name to the hashed one. That is what makes a long expires in Nginx safe, because changed content always gets a new filename. The cost is one ordering rule: a {% static %} tag for a file missing from the manifest raises a ValueError at render time, so collectstatic must finish before new workers start serving.

Test Gunicorn by hand before writing the unit

Run it in the foreground first. A unit that fails to start hides several different problems behind one status line.

sudo -u django sh -c 'set -a; . /etc/myproject/env; set +a; exec /srv/myproject/venv/bin/gunicorn --chdir /srv/myproject/app --bind 127.0.0.1:8000 myproject.wsgi:application'

myproject.wsgi:application is the import path of the application object in myproject/wsgi.py, the file django-admin startproject already wrote for you. --chdir is what makes that import path resolvable.

From a second SSH session:

curl -sI -H 'Host: example.com' http://127.0.0.1:8000/
curl -sI http://127.0.0.1:8000/

The Host header is the reason for running both. ALLOWED_HOSTS holds example.com, so the second command returns 400 Bad Request. That is ALLOWED_HOSTS doing its job. A 200 or a 302 from the first means Django, the settings and the environment file all work, and everything left to get wrong is systemd and Nginx.

--workers defaults to 1. Gunicorn's own starting point is (2 x CPU cores) + 1, then measurement under real load. Each worker is a separate process holding a full copy of your application in memory, so on a 1 GB VPS three Django workers can be the ceiling. Watch the real figure with systemd-cgtop once the unit is running, and put a hard bound on it using the controls in limiting a service's memory and CPU under systemd.

--timeout defaults to 30 seconds. A worker that has not answered inside that window is killed and replaced, the connection closes, and Nginx turns that into a 502. Raising the timeout hides a slow view rather than fixing it, so raise it only for an endpoint you know is slow and cannot move into a background job.

The systemd socket, and why it owns the permissions

Gunicorn can bind the Unix socket itself with --bind unix:/run/gunicorn.sock. Do not. Gunicorn's umask setting controls the mode of the socket file it creates and defaults to 0, so the socket lands open to every local account on the box. Set --umask 007 to close that and Nginx can no longer connect, because the socket now belongs to django:django while Nginx's workers run as www-data. The usual patch is adding www-data to the django group, which hands Nginx read access to more of your project than it has any use for, and which needs a full systemctl restart nginx rather than a reload, because supplementary groups are read when a process starts.

Let systemd create the socket instead. This is the shape Gunicorn's own documentation uses, and it puts the ownership in three readable lines.

# /etc/systemd/system/gunicorn.socket
[Unit]
Description=Gunicorn socket for myproject

[Socket]
ListenStream=/run/gunicorn.sock
SocketUser=www-data
SocketGroup=www-data
SocketMode=0660

[Install]
WantedBy=sockets.target

systemd creates and binds the socket as root, sets it to www-data:www-data mode 0660, then passes the already open file descriptor to Gunicorn at start. Gunicorn never touches the path, so its umask is irrelevant and the django account needs no permission anywhere in /run. The only account that can connect is www-data, which is exactly Nginx and nothing else.

/run is a tmpfs and is empty after every reboot, so the socket has to be recreated on the way up. That is what WantedBy=sockets.target arranges.

sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn.socket
ls -l /run/gunicorn.sock

Expect srw-rw---- 1 www-data www-data. The leading s is the file type: this is a socket, not a regular file.

The systemd service unit

# /etc/systemd/system/gunicorn.service
[Unit]
Description=Gunicorn for myproject
Requires=gunicorn.socket
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=notify
NotifyAccess=main
User=django
Group=django
WorkingDirectory=/srv/myproject/app
EnvironmentFile=/etc/myproject/env
ExecStart=/srv/myproject/venv/bin/gunicorn --workers 3 --access-logfile - myproject.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=3
KillMode=mixed
TimeoutStopSec=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true

[Install]
WantedBy=multi-user.target

There is no --bind in ExecStart. Gunicorn checks the LISTEN_FDS variable systemd sets and serves on the inherited descriptor, so a --bind here would open a second listener you did not ask for. WorkingDirectory does the job --chdir did in the foreground test.

Type=notify is the right type because Gunicorn signals systemd once it is genuinely ready. systemctl start gunicorn then blocks until the workers have imported your code, which turns a broken settings file into a failed start rather than a service that reports active and answers 502. NotifyAccess=main limits that message to the master process. If the notification never arrives, systemctl start hangs until TimeoutStartSec expires and the journal records a timeout; Type=exec is the fallback, and it treats a successful execve as started. What each systemd service type waits for explains why Type=simple appears to work here and still costs you that startup check.

Restart=always with RestartSec=3 is what a web application wants, and Gunicorn's documented example leaves it out. With no Restart= line, a master killed by the out of memory killer stays dead until somebody notices. always also covers a clean exit with status 0, which on-failure does not, and systemctl stop is still a stop, because systemd does not restart a unit you stopped deliberately. StartLimitBurst=5 with StartLimitIntervalSec=60 stops a crash loop: after five failed starts inside a minute systemd gives up and leaves the unit failed, and sudo systemctl reset-failed gunicorn clears that counter once the cause is fixed. The full set of systemd restart policies covers the other values.

KillMode=mixed sends SIGTERM to the Gunicorn master alone. The master stops accepting connections, lets in flight requests finish, and exits. Anything still running after TimeoutStopSec=10 is killed. Signal the whole control group instead and workers die in the middle of requests.

ProtectSystem=full mounts /usr, /boot, /efi and /etc read only for this service. ProtectHome=true hides /home and /root, which acts on those paths rather than on the account's passwd entry, so /srv/myproject stays writable even though it is this account's home. Put the code in a real home directory and ProtectHome breaks the service at once. ProtectSystem=strict is tighter and makes the entire filesystem read only, at which point uploads and a SQLite file need an explicit ReadWritePaths=/srv/myproject.

sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn.service
systemctl status gunicorn.service
sudo -u www-data curl -sI --unix-socket /run/gunicorn.sock -H 'Host: example.com' http://localhost/

That last command is the whole reason for the socket unit. It asks the question Nginx will ask, over the same socket, as the same account. A status line back means the permissions are right. A connection failure instead of an HTTP response means they are not, and no amount of Nginx configuration will fix that.

--access-logfile - sends the access log to stdout, and systemd captures stdout into the journal, so journalctl -u gunicorn -f shows requests and tracebacks in one stream with no log files to rotate.

Nginx in front for TLS and static files

# /etc/nginx/sites-available/myproject
upstream django_app {
    server unix:/run/gunicorn.sock;
}

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

    client_max_body_size 20m;

    location /static/ {
        alias /srv/myproject/static/;
        access_log off;
        expires 30d;
    }

    location / {
        proxy_pass http://django_app;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }
}
sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/myproject
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

nginx -t parses the configuration without touching the running server and ends with test is successful. Run it before every reload. Removing the packaged default site matters because that site answers any request whose Host your own block does not match.

alias /srv/myproject/static/; replaces the matched /static/ prefix with that path, so /static/admin/css/base.css is read from /srv/myproject/static/admin/css/base.css. Keep the trailing slash on both the location and the alias. Write alias /srv/myproject/static; without it and Nginx concatenates rather than joins, turning /static/admin/css/base.css into /srv/myproject/staticadmin/css/base.css, which is the most common cause of a 404 that looks impossible.

proxy_set_header Host $host; passes the name the client asked for, which is the value ALLOWED_HOSTS is checked against. Leave the line out and Nginx sends its own default instead, the upstream name django_app, so Django sees a host it has never heard of and answers 400 on every request.

proxy_set_header X-Forwarded-Proto $scheme; is applied to every proxied request, which overwrites anything a client sent. That unconditional overwrite is the precondition Django's documentation attaches to SECURE_PROXY_SSL_HEADER. If a visitor could smuggle their own X-Forwarded-Proto: https through, request.is_secure() would return true for a plain HTTP request.

Now the certificate. The Certbot Nginx plugin reads server_name out of the block above, requests the certificate, then rewrites the file: it adds a server block on 443 carrying the certificate paths and converts the port 80 block into a redirect.

sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run
sudo ufw allow 'Nginx Full'
sudo ss -ltnp

Installing Certbot and reading what it writes is covered in getting a Let's Encrypt certificate with Certbot on Ubuntu 24.04. Renewal runs from a timer the package installs, and renew --dry-run proves it works before you need it.

Read the ss -ltnp output carefully. It should list sshd and Nginx and nothing else. No process on 8000, and no application port at all, because Gunicorn holds a Unix socket. That absence is the security payoff of the socket.

Ubuntu 24.04 ships Nginx 1.24, where HTTP/2 is enabled by adding http2 to the listen line, listen 443 ssl http2;. Nginx 1.25.1 moved it into a separate http2 on; directive and the old form now logs a deprecation warning. Run nginx -v before copying either form from somewhere else.

The four things that break first

DEBUG left on. Nothing errors, which is the problem. The site works, and then one uncaught exception hands your settings, your installed apps and a full traceback to whoever triggered it. Check from outside the box with curl -s https://example.com/no-such-page | head -c 300. With DEBUG = False you get Django's short 404 page. With DEBUG = True you get a long page listing your URL patterns. manage.py check --deploy reports the same fault as security.W018. DEBUG = True also makes Django serve static files itself, and relaxes ALLOWED_HOSTS to a localhost list, so leaving it on conceals the next two problems until the day you turn it off.

ALLOWED_HOSTS. With DEBUG = False, a Host header matching nothing in the list makes request.get_host() raise SuspiciousOperation, and Django answers 400 Bad Request before your view runs. Every page fails the same way, which is why this reads as a broken deploy rather than a settings problem. Compare a good host with a bad one: curl -sI -H 'Host: example.com' https://example.com/ against curl -sI -H 'Host: wrong.test' https://example.com/. Two traps live here. The environment variable pattern above turns a missing DJANGO_ALLOWED_HOSTS into an empty list, which check --deploy reports as security.W020, so run that check after every edit to the environment file. And an uptime monitor that requests the server's IP address rather than the hostname gets a 400 until that address is in the list too.

Static files 404. A 404 on https://example.com/static/admin/css/base.css while the pages themselves load means Nginx looked and found nothing. Either collectstatic never ran, or alias and STATIC_ROOT name different directories, or the trailing slash on alias is missing. Confirm the file exists on disk first with ls -l, then compare that exact path against the alias line. A 403 on the same URL is a different fault: the file is there and Nginx cannot read it. Reading a file needs execute permission on every directory above it, and /srv/myproject is mode 0750 when adduser creates it, so www-data cannot traverse it. namei -om /srv/myproject/static/admin/css/base.css prints the mode of each path component, and the offending one is visible in that output. sudo chmod 755 /srv/myproject fixes it, and Nginx records an open() ... failed (13: Permission denied) line naming the file in /var/log/nginx/error.log.

Socket permissions. Nginx answers 502 Bad Gateway and the request never reaches Django, so the Gunicorn journal has nothing to show. Nginx's error log separates the two causes and both name the socket path: a connect() to unix:/run/gunicorn.sock failed line with (13: Permission denied) is ownership, and the same line with (2: No such file or directory) means the socket is absent, usually because gunicorn.socket is not enabled or ListenStream and proxy_pass disagree about the path. Reproduce it with Nginx out of the way using sudo -u www-data curl -sI --unix-socket /run/gunicorn.sock -H 'Host: example.com' http://localhost/. If that returns a status line and Nginx still answers 502, the fault is in the Nginx config rather than the socket.

Deploys, reboots, and what stays out of scope

A deploy is five steps, and the order is not arbitrary.

sudo -u django git -C /srv/myproject/app pull
sudo -u django /srv/myproject/venv/bin/pip install -r /srv/myproject/app/requirements.txt
sudo -u django sh -c 'set -a; . /etc/myproject/env; set +a; exec /srv/myproject/venv/bin/python /srv/myproject/app/manage.py migrate --noinput'
sudo -u django sh -c 'set -a; . /etc/myproject/env; set +a; exec /srv/myproject/venv/bin/python /srv/myproject/app/manage.py collectstatic --noinput'
sudo systemctl reload gunicorn

collectstatic runs before the reload because the new workers read staticfiles.json when they render templates, and a tag for a file missing from the manifest raises there. migrate runs before the reload for the mirror image of that reason: the old code has to survive the new schema for the few seconds both are live, which is why adding a column with a default is safe and renaming one is not. systemctl reload gunicorn sends SIGHUP, which brings up workers on the new code and retires the old ones as they finish their requests, so nothing in flight is dropped. A reload with code that cannot be imported can take the service down with it, so run manage.py check first.

Two units have to be enabled for a reboot to bring the site back. systemctl is-enabled gunicorn.socket gunicorn.service nginx should print enabled three times. The socket unit is the one that must be enabled for the socket path to exist at all after boot. Enabling the service as well starts the workers during boot rather than on the first request, which is what you want once the site has real traffic, because the first visitor should not pay for the Python import.

Database choice is out of scope here on purpose. SQLite on a single VPS is an honest answer for low write volume, and PostgreSQL is the usual next step. Either way the credentials belong in the same /etc/myproject/env file that DATABASES reads through os.environ, and a SQLite file needs an explicit ReadWritePaths= once you tighten ProtectSystem to strict.

What remains is ordinary server hygiene. The django account cannot log in, owns nothing outside /srv/myproject, and holds no sudo rights, which is the pattern in giving each service its own least privilege account. Keep it that way when you add a background worker later, and give that worker its own account rather than reusing this one.

FAQ

Why do my Django static files 404 after I set DEBUG to False?

Because Django only serves static files itself while DEBUG is True. With DEBUG = False the staticfiles app stops handling those requests, and serving the files becomes the web server's job. Run manage.py collectstatic --noinput to copy every app's static files into STATIC_ROOT, then give Nginx a location /static/ whose alias points at that exact directory with matching trailing slashes on both. If the URL returns 403 rather than 404, the file exists and Nginx cannot read it: check that www-data has execute permission on every directory above STATIC_ROOT, using namei -om on the full path to one collected file.

Should Gunicorn bind a Unix socket or a TCP port?

A Unix socket, for two reasons. There is no port for anything on the internet to reach, so Nginx becomes the only route into the application. And the socket's file permissions become the access control, so exactly one account can connect. Create the socket from a systemd .socket unit with SocketUser, SocketGroup and SocketMode=0660 rather than letting Gunicorn bind it, because Gunicorn's umask setting defaults to 0 and produces a socket open to every local account. Use a TCP port only when Nginx runs on a different host from Gunicorn.

Which systemd service type should a Gunicorn unit use?

Type=notify with NotifyAccess=main, which is what Gunicorn's own documentation shows. Gunicorn signals systemd when its workers are actually ready, so systemctl start fails on a broken settings file instead of returning success for a service that answers 502. Type=simple reports the unit active the moment the process is forked, before your code has been imported, so a start that failed still looks like a start that worked. If the readiness notification never arrives, systemctl start hangs until TimeoutStartSec and the journal records a timeout; Type=exec is the fallback there.

Why does Django return 400 Bad Request behind Nginx?

Almost always ALLOWED_HOSTS. With DEBUG = False, Django compares the request's Host header against that list and raises SuspiciousOperation when nothing matches, which becomes a 400 before any view runs. Check two things in order. Nginx must forward the real name with proxy_set_header Host $host;, because its default sends the upstream name instead. Then confirm the value reaches Django at all: manage.py check --deploy reports security.W020 when ALLOWED_HOSTS is empty, which is what happens when the environment file is not being read.

Why does Nginx return 502 Bad Gateway when Gunicorn is running?

A 502 means Nginx could not complete a request to the upstream, so read /var/log/nginx/error.log first, because it names the socket path and the reason. (13: Permission denied) on connect() is socket ownership. (2: No such file or directory) means the socket is not there, usually an unenabled gunicorn.socket or a path mismatch between ListenStream and proxy_pass. If the socket is fine, the other common cause is a worker killed at Gunicorn's 30 second --timeout, which closes the connection mid request, and journalctl -u gunicorn shows that worker being replaced. Test the socket directly with sudo -u www-data curl -sI --unix-socket /run/gunicorn.sock -H 'Host: example.com' http://localhost/.