Fix Gunicorn "Permission denied" Binding to Port 80
Fix Gunicorn Connection in use or Permission denied when binding to port 80 or 443: bind Gunicorn to a high port or Unix socket behind Nginx, or grant CAP_NET_BIND_SERVICE safely instead of running as root.
Problem statement
Starting Gunicorn on the standard web port fails:
[ERROR] Can't connect to ('0.0.0.0', 80)
[ERROR] Retrying in 1 second.
...
PermissionError: [Errno 13] Permission denied
On Linux, ports below 1024 are privileged: only root, or a process with the CAP_NET_BIND_SERVICE capability, can bind them. Gunicorn running as an ordinary user, as it should, cannot open port 80 or 443. The standard fix is not to make Gunicorn bind those ports at all: put Nginx or Caddy on 80 and 443 and let Gunicorn listen on a Unix socket or a high local port.
- Bind Gunicorn to a socket or high port:
--bind unix:/run/gunicorn/gunicorn.sockor--bind 127.0.0.1:8000. - Put Nginx (or Caddy) on ports 80 and 443 and proxy to Gunicorn.
- If Gunicorn must face the network directly (for example inside a container behind a load balancer), use port 8000 and map it.
- Only if you really need Gunicorn on 80: grant
AmbientCapabilities=CAP_NET_BIND_SERVICEin its systemd unit. - Never run Gunicorn as root to get past this error.
Step-by-step solution
1. Move Gunicorn off the privileged port
In the systemd unit:
[Service]
User=deploy
Group=www-data
RuntimeDirectory=gunicorn
ExecStart=/srv/app/.venv/bin/gunicorn config.wsgi:application \
--workers 3 \
--bind unix:/run/gunicorn/gunicorn.sock
RuntimeDirectory= creates /run/gunicorn owned by the service user on each start. A Unix socket is not reachable from the network at all, which is a security benefit, and avoids port conflicts between projects.
2. Let Nginx own 80 and 443
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://unix:/run/gunicorn/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Nginx's master process starts as root precisely so it can bind privileged ports, then its workers drop to an unprivileged user. It also handles TLS, static files, slow clients, and buffering, all of which Gunicorn's sync workers are poor at.
3. Containers: use a high port inside
In Docker, bind Gunicorn to 0.0.0.0:8000 inside the container and publish or route to it:
USER app
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
services:
web:
expose: ["8000"] # reachable by the proxy container
Behind a cloud load balancer or Kubernetes Service, the external port 80/443 maps to container port 8000; Gunicorn never needs to bind 80. Recent Docker versions also set net.ipv4.ip_unprivileged_port_start=0 inside containers, but relying on that couples you to the runtime.
4. If you must: grant the capability
For a single-binary setup with no proxy, systemd can give just the bind capability to a non-root user:
[Service]
User=deploy
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ExecStart=/srv/app/.venv/bin/gunicorn config.wsgi:application --bind 0.0.0.0:80
Avoid setcap on the Python binary itself: it grants the capability to every script run with that interpreter.
5. Do not run as root
sudo gunicorn ... --bind 0.0.0.0:80 works, and gives every Django request, template, and dependency root privileges. Any remote code execution bug then owns the whole server. Gunicorn's --user option drops privileges after binding, but a proxy is simpler and safer.
Verification check:
sudo ss -ltnp | grep -E ':80 |gunicorn'
ps -o user,cmd -C gunicorn | head -3
curl -sI http://example.com | head -1
Port 80 should belong to Nginx, Gunicorn processes should run as your app user, and the site should respond.
Explanation
The privileged port rule dates from multi-user Unix systems, where it stopped ordinary users from impersonating system services such as a web server. Linux still enforces it by default. The reverse-proxy pattern sidesteps it cleanly: only the proxy, a small and hardened program designed to start as root and drop privileges, binds public ports. Gunicorn and Django run as a dedicated user with no special privileges, which limits the damage of any application bug.
Edge cases / notes
- "Address already in use" on port 80 is a different error: another process holds the port.
--userand--groupoptions let Gunicorn bind as root then drop privileges, but master startup and config loading still run as root.- SELinux can deny binding non-standard ports even for allowed users; label the port with
semanage port. - Socket permissions. Nginx must be able to read and write the socket; matching groups avoid the socket permission error.
Next steps
- Set up the standard stack with Deploy Django With Gunicorn and Nginx on Ubuntu.
- Write a robust unit with Set Up systemd for Gunicorn and Django.
FAQ
Why can't Gunicorn use port 80?
Ports below 1024 require root or the CAP_NET_BIND_SERVICE capability, and Gunicorn should run as a normal user.
Is running Gunicorn as root acceptable?
No. It gives every request handler root privileges. Use a proxy or a capability instead.
Should Gunicorn use a socket or a port?
A Unix socket behind Nginx on the same host; a high TCP port in containers or behind a load balancer.
Do I need Nginx at all?
For production, a proxy handles TLS, static files, and slow clients far better than Gunicorn alone.