Fix Gunicorn Socket Permission Denied (Nginx + Django)
Fix Permission denied on a Gunicorn Unix socket: socket ownership and mode, directory traversal, systemd RuntimeDirectory, umask, and SELinux.
Problem statement
Nginx returns 502 Bad Gateway, and its error log contains:
connect() to unix:/run/myapp/gunicorn.sock failed (13: Permission denied)
while connecting to upstream, client: 203.0.113.7, server: www.example.com,
request: "GET / HTTP/1.1", upstream: "http://unix:/run/myapp/gunicorn.sock:/"
Gunicorn is running and the socket file exists, but the Nginx worker process cannot open it. Error 13 is the operating system's EACCES: permission denied.
This is different from the neighbouring failures:
(2: No such file or directory): the socket path does not exist, usually because Gunicorn is not running or binds elsewhere(111: Connection refused): something is listening on a TCP port but refusing, or the socket is stale with no process behind it403 Forbiddenon static files: a filesystem permission problem in the static file path rather than the socket
Unix sockets are files. Access depends on the socket's owner, group, and mode, and on execute (traverse) permission for every directory in its path. Getting those right once, in the systemd unit, makes the problem permanent rather than something you re-fix after every reboot.
Quick answer
- Read the exact socket path from the Nginx error log.
- Check the socket:
ls -l /run/myapp/gunicorn.sockand every parent directory withnamei -l. - Run Gunicorn as a dedicated app user with
Group=www-data(the Nginx user's group on Debian and Ubuntu) and--umask 007, so the socket is group-accessible. - Create the socket's directory with
RuntimeDirectory=in the systemd unit, so it exists after every reboot with the right ownership. - Confirm the Nginx worker user matches the socket's group.
- On RHEL-family systems, check SELinux denials.
- Reload both services and verify with a request through Nginx.
Step-by-step solution
1. Confirm the failing path and the two users involved
sudo tail -n 20 /var/log/nginx/error.log | grep "Permission denied"
ps -o user,cmd -C nginx | sort -u | head -3
ps -o user,group,cmd -C gunicorn | sort -u | head -3
The Nginx master runs as root, but its workers run as www-data (Debian and Ubuntu) or nginx (RHEL family). The worker user is the one that must reach the socket.
2. Inspect the socket and its path
ls -l /run/myapp/gunicorn.sock
namei -l /run/myapp/gunicorn.sock
A working setup looks like this:
srw-rw---- 1 myapp www-data 0 Sep 17 09:12 /run/myapp/gunicorn.sock
- owner
myapp: the application user that created it - group
www-data: the Nginx worker's group - mode
srw-rw----(660): owner and group can read and write, others cannot
namei -l shows every directory on the path. Each one needs at least execute permission for www-data:
f: /run/myapp/gunicorn.sock
drwxr-xr-x root root /
drwxr-xr-x root root run
drwxr-xr-x myapp www-data myapp
srw-rw---- myapp www-data gunicorn.sock
If a directory shows drwx------, Nginx cannot enter it regardless of the socket's own mode.
3. Test as the Nginx user
sudo -u www-data test -r /run/myapp/gunicorn.sock && echo "readable" || echo "NOT readable"
sudo -u www-data python3 -c "
import socket
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(3)
s.connect('/run/myapp/gunicorn.sock')
print('connected')
s.close()
"
A successful connection here proves the permission problem is solved; a failure reproduces exactly what Nginx sees, without restarting anything.
4. Fix the systemd unit (the durable fix)
[Unit]
Description=Gunicorn for myapp
After=network.target
[Service]
Type=notify
User=myapp
Group=www-data
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp/env
RuntimeDirectory=myapp
RuntimeDirectoryMode=0750
ExecStart=/srv/myapp/venv/bin/gunicorn config.wsgi:application \
--bind unix:/run/myapp/gunicorn.sock \
--umask 007 \
--workers 3 \
--access-logfile -
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
[Install]
WantedBy=multi-user.target
Why each line matters:
User=myapp: the application runs as its own unprivileged user. Do not run Gunicorn aswww-data; that gives the application the web server's identity and access to everything the web server can read.Group=www-data: the socket inherits this group, so Nginx workers can connect through group permission.RuntimeDirectory=myapp: systemd creates/run/myappon start, owned byUserandGroup, and removes it on stop./runis a tmpfs recreated at boot, which is why a manually created directory disappears after a reboot.RuntimeDirectoryMode=0750: owner full access, group traverse and read, others nothing.--umask 007: Gunicorn creates the socket with mode770masked to660, so the group can use it. Without it, Gunicorn's default umask can produce a socket others can also reach, or, combined with a restrictive process umask, one the group cannot.
Apply the change:
sudo systemctl daemon-reload
sudo systemctl restart myapp
ls -l /run/myapp/gunicorn.sock
5. Point Nginx at the same path
upstream myapp {
server unix:/run/myapp/gunicorn.sock;
}
server {
listen 80;
server_name www.example.com;
location / {
proxy_pass http://myapp;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
sudo nginx -t && sudo systemctl reload nginx
The path in proxy_pass must match --bind exactly, including the directory.
6. SELinux on RHEL, Rocky, Alma, and Fedora
If permissions look correct and the error persists, SELinux may be denying the connection:
getenforce
sudo ausearch -m avc -ts recent | grep -i nginx | tail -5
A denial mentioning unix_stream_socket and httpd_t means the socket's label is not one Nginx may use. Options:
# Allow the web server to connect to network/socket resources
sudo setsebool -P httpd_can_network_connect 1
# Label the socket directory as writable web content
sudo semanage fcontext -a -t httpd_var_run_t "/run/myapp(/.*)?"
sudo restorecon -Rv /run/myapp
Keep SELinux enforcing; labelling is the correct fix, not disabling it.
7. Verify
curl -sI --unix-socket /run/myapp/gunicorn.sock http://localhost/ -H "Host: www.example.com" | head -1
curl -sI http://127.0.0.1/ -H "Host: www.example.com" | head -1
sudo grep -c "Permission denied" /var/log/nginx/error.log
sudo systemctl restart myapp && sleep 2 && ls -l /run/myapp/gunicorn.sock
Verification check:
- a request directly to the socket returns an HTTP status line
- the same request through Nginx returns the same status
- no new
Permission deniedentries appear in the Nginx error log - after a service restart, the socket is recreated with owner
myapp, groupwww-data, mode660 - after a full reboot, the site comes back without manual intervention
Explanation
A Unix domain socket is a filesystem object with an owner, a group, and a mode, and the kernel checks those permissions when a process calls connect(). Nginx workers therefore need either group access to the socket (the setup above) or world access (which would let any local process talk to your application server). Because /run is wiped on every boot, the durable answer is to let systemd create the directory and let Gunicorn create the socket with the right group and umask, instead of fixing permissions by hand after each restart.
Edge cases / notes
The socket file is missing entirely
ls reports "No such file or directory", and Nginx logs error 2 rather than 13. Gunicorn is not running, failed to start, or binds to a different path; check systemctl status and the journal.
Sockets in the project directory
Older tutorials place the socket in /srv/myapp or a home directory. That works but makes permissions depend on the project directory's mode, and the socket can survive crashes as a stale file. /run managed by systemd avoids both problems.
Containers
In Docker, sharing a Unix socket between an application container and an Nginx container requires a shared volume and matching numeric UIDs or GIDs, because usernames do not map across containers. Many teams simply use a TCP port on the internal network instead.
Sockets versus TCP
Unix sockets avoid the TCP stack and are slightly faster for same-host communication; a local TCP port (127.0.0.1:8000) is easier to test with curl and works across containers. Either is fine for production.
Common mistakes
- Running Gunicorn as
www-datato "fix" permissions, which gives the app the web server's identity. - Running
chmod 777on the socket or its directory, allowing any local process to reach the application. - Creating
/run/myappby hand; it disappears on the next reboot. - Changing
proxy_passbut not--bind(or the reverse), so the paths no longer match.
Next steps
- If the socket is missing rather than unreadable, see Fix Nginx Not Connecting to Gunicorn (Connection Refused).
- For the broader failure mode, read Django 502 Bad Gateway: Causes and Fixes.
- Review the whole unit file in How to Configure systemd for Gunicorn and Django.
- For static file permission errors, see Fix Nginx 403 Forbidden on Django Static Files.
FAQ
Why does Nginx get permission denied on the Gunicorn socket?
The Nginx worker user is not the socket's owner and is not in its group, or a directory on the socket's path does not grant it execute permission.
Should Gunicorn run as www-data?
No. Run it as a dedicated application user and set Group=www-data so the socket is group-accessible. Sharing the web server's user gives the application more access than it needs.
What socket permissions should Gunicorn use?
Mode 660 with the application user as owner and the Nginx group as group. Gunicorn's --umask 007 produces that from its default socket creation mode.
Why does the socket disappear after a reboot?
/run is a temporary filesystem recreated at boot. Use RuntimeDirectory= in the systemd unit so the directory is created automatically with the correct ownership each time the service starts.