Deploy Django on Rocky Linux with SELinux
Deploy Django with Gunicorn and Nginx on Rocky Linux, AlmaLinux, or RHEL with SELinux enforcing: firewalld, file contexts, booleans, and reading AVC denials.
Problem statement
Most Django deployment tutorials assume Ubuntu. On Rocky Linux, AlmaLinux, or Red Hat Enterprise Linux, the same steps mostly work, but SELinux is enforcing by default, and it blocks things Ubuntu allows: Nginx connecting to Gunicorn, Nginx reading static files outside standard web directories, and services reading files from home directories. The symptoms are 502 Bad Gateway or 403 Forbidden responses with file permissions that look correct.
The wrong fix is setenforce 0. This guide deploys Django on an Enterprise Linux 9 system with SELinux left enforcing, using the correct booleans and file contexts, and shows how to read denials when something is blocked.
- Install Python, Nginx, and build dependencies with
dnf; Certbot comes from EPEL. - Open HTTP and HTTPS in firewalld.
- Put the app under
/srv/myapp, not in a home directory. - Run Gunicorn under systemd, bound to
127.0.0.1:8000. - Allow Nginx to proxy with
setsebool -P httpd_can_network_connect 1. - Label static and media directories
httpd_sys_content_twithsemanage fcontextandrestorecon. - When something is blocked, read denials with
ausearch -m avc -ts recentinstead of disabling SELinux.
Step-by-step solution
1. Install packages
sudo dnf -y update
sudo dnf -y install python3.12 python3.12-devel gcc libpq-devel nginx git policycoreutils-python-utils
sudo dnf -y install epel-release
sudo dnf -y install certbot python3-certbot-nginx
policycoreutils-python-utils provides semanage and audit2why. Check getenforce; it should print Enforcing.
2. Open the firewall
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload
sudo firewall-cmd --list-services
Do not open port 8000; Gunicorn is reached only through Nginx on localhost.
3. Create the app user and directories
sudo useradd --system --create-home --home-dir /srv/myapp --shell /sbin/nologin myapp
sudo -u myapp git clone https://github.com/example/myapp.git /srv/myapp/app
sudo -u myapp python3.12 -m venv /srv/myapp/venv
sudo -u myapp /srv/myapp/venv/bin/pip install -r /srv/myapp/app/requirements.txt gunicorn
sudo mkdir -p /srv/myapp/static /srv/myapp/media
sudo chown myapp:myapp /srv/myapp/static /srv/myapp/media
sudo chmod 755 /srv/myapp
In Django settings, set STATIC_ROOT = "/srv/myapp/static" and MEDIA_ROOT = "/srv/myapp/media". Keeping the app out of /home avoids the separate httpd_enable_homedirs boolean and the home directory permissions it requires.
4. Run Gunicorn with systemd
# /etc/systemd/system/myapp.service
[Unit]
Description=myapp Gunicorn
After=network.target
[Service]
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp.env
ExecStart=/srv/myapp/venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 3 --access-logfile -
Restart=on-failure
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
curl -sI -H "Host: example.com" http://127.0.0.1:8000/ | head -1
A TCP socket on localhost is the simplest option under SELinux. Nginx runs in the confined httpd_t domain, and connecting it to a Unix socket created by a service in another domain needs a custom policy module; a localhost TCP port needs only a boolean.
5. Allow Nginx to connect to Gunicorn
sudo setsebool -P httpd_can_network_connect 1
getsebool httpd_can_network_connect
Without this boolean, Nginx logs connect() to 127.0.0.1:8000 failed (13: Permission denied) and returns 502, even though Gunicorn is running. -P makes the change persistent across reboots.
6. Label static and media files
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/myapp/static(/.*)?"
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/myapp/media(/.*)?"
sudo restorecon -Rv /srv/myapp/static /srv/myapp/media
ls -Zd /srv/myapp/static
semanage fcontext records the rule in policy, and restorecon applies it. Files created later inside these directories inherit the directory's type, and any future restorecon run keeps them correct. Using chcon instead works until the next relabel, then silently reverts.
Nginx also needs normal Unix permissions: every parent directory must be traversable (x) by the nginx user, and files readable.
7. Configure Nginx
On Enterprise Linux, Nginx includes /etc/nginx/conf.d/*.conf and runs as the nginx user.
# /etc/nginx/conf.d/myapp.conf
server {
listen 80;
server_name example.com www.example.com;
client_max_body_size 20m;
location /static/ {
alias /srv/myapp/static/;
}
location /media/ {
alias /srv/myapp/media/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
sudo -u myapp bash -c 'set -a; . /etc/myapp.env; set +a; cd /srv/myapp/app && /srv/myapp/venv/bin/python manage.py collectstatic --noinput'
sudo nginx -t
sudo systemctl enable --now nginx
sudo certbot --nginx -d example.com -d www.example.com
8. Read and fix denials
When a request fails and permissions look fine, check the audit log:
sudo ausearch -m avc -ts recent
sudo ausearch -m avc -ts recent | audit2why
audit2why usually names the boolean or label that would allow the access. Typical results:
| Denial | Fix |
|---|---|
httpd_t to http_port_t or unreserved port | setsebool -P httpd_can_network_connect 1 |
httpd_t read on var_t or user_home_t files | label the directory httpd_sys_content_t |
httpd_t connect to PostgreSQL port | setsebool -P httpd_can_network_connect_db 1 (only if Nginx itself connects) |
Only use audit2allow to generate a custom module after confirming no boolean or label covers the case.
Verification check:
getenforce
curl -sI https://example.com/ | head -1
curl -sI https://example.com/static/admin/css/base.css | head -1
sudo ausearch -m avc -ts recent
Expect Enforcing, successful responses, and no new denials.
Explanation
SELinux assigns every process a domain and every file a type, and the policy lists which domains may access which types. Nginx runs as httpd_t; by default it may read httpd_sys_content_t files and may not open arbitrary network connections. Gunicorn started by systemd from /srv typically runs unconfined, so it can reach PostgreSQL and write media without extra rules. The two things Nginx needs (connecting to Gunicorn and reading static and media files) are covered by one boolean and one file-context rule, which keeps the rest of the system protected.
Edge cases / notes
Upload directories and restorecon
Media written by Gunicorn inherits the directory type, so Nginx can serve it. If you move files in with mv from elsewhere, they keep their old label; run restorecon -Rv on the media directory afterwards.
Permissive mode for diagnosis only
sudo setenforce 0 temporarily switches to permissive mode, which logs denials without blocking them. It is useful for confirming that SELinux is the cause, but switch back with setenforce 1 immediately and fix the policy properly.
When to automate this
The booleans, semanage fcontext rules, and firewalld services belong in a provisioning script or Ansible role (ansible.posix.seboolean, community.general.sefcontext) so every new server gets identical policy. Pair them with a smoke test that checks getenforce and a static file URL.
Next steps
- Refine the service unit with How to Configure systemd for Gunicorn and Django.
- Tune the proxy with How to Configure Nginx for Django in Production.
- For static file 403s on any distribution, see Fix Nginx 403 Forbidden on Django Static Files.
- Finish with the Linux Server Hardening Checklist for Django.
FAQ
Should I disable SELinux to deploy Django?
No. Django needs only a boolean for proxying and file labels for static and media directories. Disabling SELinux removes a strong protection layer for the whole server.
Why does Nginx return 502 on Rocky Linux when Gunicorn is running?
SELinux blocks Nginx from connecting to the Gunicorn port by default. Enable httpd_can_network_connect and check ausearch -m avc for the denial.
Why does Nginx return 403 for static files on RHEL even with correct permissions?
The files carry a type Nginx may not read, such as var_t or user_home_t. Label the directory httpd_sys_content_t with semanage fcontext and run restorecon.
Can Nginx proxy to a Gunicorn Unix socket with SELinux enforcing?
Yes, but it usually needs a custom policy module allowing httpd_t to connect to the service's domain. A localhost TCP port with the standard boolean is simpler to maintain.