How to set up Django application with Postgres, Nginx, and Gunicorn on Rocky Linux/Alma Linux 9

Deploy Django on Rocky Linux 9 or AlmaLinux 9 with PostgreSQL, Gunicorn as a WSGI app server, and Nginx as a reverse proxy—systemd, static files, permissions, and production-oriented notes.

This guide walks you through a production-style Django deployment on Rocky Linux 9 or AlmaLinux 9: PostgreSQL for the database, Gunicorn to run the WSGI application, and Nginx as the public-facing reverse proxy. You will use a Python virtual environment, systemd to supervise Gunicorn, and Unix sockets (rather than exposing Gunicorn on a TCP port) so only Nginx talks to your app process.

What you will set up

  • System packages, EPEL, and PostgreSQL 14 from the PostgreSQL Global Development Group (PGDG) YUM repository
  • A dedicated project layout under /opt/djangoapp with a venv and a Django project
  • Database configuration via environment variables (no secrets in Git)
  • Static file collection for Nginx to serve directly
  • A systemd unit for Gunicorn and an Nginx server block that proxies to a Unix socket

Related guides

Prerequisites

  • A Rocky Linux 9 or AlmaLinux 9 server (the commands are the same on both for this tutorial)
  • SSH access with sudo
  • A domain name (optional for testing with an IP, recommended for TLS and ALLOWED_HOSTS)
  • Basic familiarity with the shell, vim or another editor, and Django’s manage.py commands

You will install Django inside a virtual environment so project dependencies stay isolated and upgrades are predictable.

1. Update the system and enable EPEL

Refresh installed packages, then install EPEL (Extra Packages for Enterprise Linux), which provides additional dependencies some Python stacks expect:

1
2
sudo dnf update -y
sudo dnf install -y epel-release

2. SELinux (know what you are changing)

This article focuses on the application stack; a full SELinux policy for Django, PostgreSQL, and Nginx is its own topic. If you are learning on a throwaway VM, some guides disable SELinux for simplicity—on real servers, prefer leaving SELinux enforcing and fixing contexts or booleans instead of disabling security wholesale.

If you still choose to disable it persistently, edit /etc/selinux/config and set the mode (not “mod”) to disabled:

1
2
3
4
5
6
# This file controls the state of SELinux on the system.
# SELINUX= can take one of these three values:
#       enforcing - SELinux security policy is enforced.
#       permissive - SELinux prints warnings instead of enforcing.
#       disabled - No SELinux policy is loaded.
SELINUX=disabled

To switch to permissive mode until the next reboot (diagnostics only):

1
2
sudo setenforce 0
sestatus

3. Install build tools, Python, and PostgreSQL

Install Python and headers so pip can build packages when needed:

1
sudo dnf install -y python3 python3-devel python3-pip gcc

Add the PGDG repository and install PostgreSQL 14 server, client tools, and development headers (useful if you compile psycopg2):

1
2
3
sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
sudo dnf -qy module disable postgresql 2>/dev/null || true
sudo dnf install -y postgresql14-server postgresql14-contrib postgresql14-devel python3-psycopg2

For a deeper walkthrough of PostgreSQL on this OS, see How to Install and Configure Postgres 14 on Rocky Linux 9.

Initialize the data directory (once per machine):

1
2
sudo /usr/pgsql-14/bin/postgresql-14-setup initdb
sudo systemctl enable --now postgresql-14

4. Create database and role for Django

Switch to the postgres OS user and open psql:

1
sudo -iu postgres psql

In the SQL prompt, replace the example names and password. Prefer a strong password and store it in a password manager until you copy it into an env file on the server (see systemd section).

1
2
3
4
5
6
7
CREATE DATABASE djangoapp;
CREATE USER djangouser WITH ENCRYPTED PASSWORD 'replace_with_a_strong_password';
GRANT ALL PRIVILEGES ON DATABASE djangoapp TO djangouser;
ALTER DATABASE djangoapp OWNER TO djangouser;
\c djangoapp
GRANT ALL ON SCHEMA public TO djangouser;
\q

On PostgreSQL 15+, default public schema permissions are stricter; the GRANT … ON SCHEMA public pattern becomes even more important. PostgreSQL 14 still benefits from explicit grants for clarity.

5. Create a virtual environment and install Django

Pick a deployment user (examples below use appuser). Create that user if you do not want to run the app as your personal login:

1
sudo useradd -m -d /opt/djangoapp -s /bin/bash appuser

As that user, create the environment with the standard library (no sudo pip needed):

1
2
3
4
sudo -iu appuser
cd /opt/djangoapp
python3 -m venv appenv
source appenv/bin/activate

If pip cannot find pg_config when building psycopg2, expose the PostgreSQL 14 binaries first:

1
export PATH="/usr/pgsql-14/bin:$PATH"

Install Django, Gunicorn, and one PostgreSQL adapter approach: either psycopg2 (links against system libpq—good when postgresql14-devel is present) or psycopg2-binary (prebuilt wheels—convenient for development). For this server setup with postgresql14-devel installed:

1
2
pip install --upgrade pip
pip install django gunicorn "psycopg2>=2.9"

6. Create the Django project and configure the database

Still in /opt/djangoapp with the venv active:

1
django-admin startproject myapp .

This creates manage.py in /opt/djangoapp/ and the Python package myapp/ beside it (/opt/djangoapp/myapp/settings.py, wsgi.py, and so on). Stay in /opt/djangoapp when you run manage.py commands.

Edit myapp/settings.py (path: /opt/djangoapp/myapp/settings.py):

  1. Ensure import os is present at the top (Django’s startproject template includes it).
  2. Set ALLOWED_HOSTS to your real domain or IP (never leave * in production).
  3. Replace the default SQLite DATABASES block with PostgreSQL, reading from the environment.

Use the modern engine name django.db.backends.postgresql (preferred in current Django; postgresql_psycopg2 is an older alias):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "change-me-only-for-local-testing")

ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(",")

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ.get("DATABASE_DB", "djangoapp"),
        "USER": os.environ.get("DATABASE_USER", "djangouser"),
        "PASSWORD": os.environ.get("DATABASE_PASSWORD", ""),
        "HOST": os.environ.get("DATABASE_HOST", "127.0.0.1"),
        "PORT": os.environ.get("DATABASE_PORT", "5432"),
    },
}
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static/")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media/")

Migrate, create admin, collect static

Export variables (or rely on systemd in the next section), then run migrations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export DJANGO_SECRET_KEY="$(openssl rand -base64 48)"
export DJANGO_ALLOWED_HOSTS="127.0.0.1,your-domain.example"
export DATABASE_DB=djangoapp
export DATABASE_USER=djangouser
export DATABASE_PASSWORD='replace_with_a_strong_password'
export DATABASE_HOST=127.0.0.1
export DATABASE_PORT=5432

python manage.py migrate
python manage.py createsuperuser
python manage.py collectstatic --noinput

Quick checks: dev server and Gunicorn

1
python manage.py runserver 0.0.0.0:8000

Visit http://YOUR_SERVER_IP:8000 and /admin. Stop with Ctrl+C.

Test Gunicorn on a TCP port (temporary):

1
gunicorn --bind 0.0.0.0:8000 myapp.wsgi:application

Admin CSS will still look bare until Nginx serves STATIC_ROOT; that is expected. Stop Gunicorn with Ctrl+C, then deactivate if you are done with the venv shell.

7. systemd unit for Gunicorn (Unix socket)

Do not embed real SECRET_KEY or database passwords in the unit file checked into documentation—on the server, use root-only permissions on an EnvironmentFile.

Create /etc/django/myapp.env (mode 600, owned by root):

1
2
3
4
sudo mkdir -p /etc/django
sudo install -m 600 /dev/null /etc/django/myapp.env
sudo chown root:root /etc/django/myapp.env
sudo vim /etc/django/myapp.env

Example contents (generate a new secret for production):

1
2
3
4
5
6
7
8
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=your-domain.example,www.your-domain.example
DJANGO_SECRET_KEY=PASTE_A_LONG_RANDOM_SECRET_HERE
DATABASE_DB=djangoapp
DATABASE_USER=djangouser
DATABASE_PASSWORD=replace_with_a_strong_password
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432

Create /etc/systemd/system/myapp.service:

1
sudo vim /etc/systemd/system/myapp.service
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[Unit]
Description=Gunicorn for myapp Django project
Wants=network-online.target
After=network-online.target postgresql-14.service

[Service]
Type=simple
User=appuser
Group=nginx
WorkingDirectory=/opt/djangoapp
EnvironmentFile=/etc/django/myapp.env
ExecStart=/opt/djangoapp/appenv/bin/gunicorn \
    --bind unix:/opt/djangoapp/app.sock \
    --workers 3 \
    --timeout 120 \
    --access-logfile - \
    --error-logfile - \
    myapp.wsgi:application

Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Notes:

  • Type=simple is widely compatible with Gunicorn; newer Gunicorn builds can use Type=notify if you enable systemd integration in your Gunicorn configuration.
  • Omit --reload in production; it adds overhead and is intended for development.
  • Group=nginx helps when you set socket permissions so Nginx can read the Unix socket.

Reload systemd and start the app:

1
2
3
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp

If the socket is not yet group-readable, after the first successful start:

1
2
sudo chmod 660 /opt/djangoapp/app.sock
sudo chown appuser:nginx /opt/djangoapp/app.sock

(or ensure your Gunicorn umask/group settings create it correctly from the first request).

8. Nginx reverse proxy

Install Nginx if needed:

1
sudo dnf install -y nginx

Create /etc/nginx/conf.d/myapp.conf (adjust server_name and paths):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
server {
    listen 80;
    server_tokens off;
    client_max_body_size 100M;
    server_name your-domain.example;

    location = /favicon.ico {
        alias /opt/djangoapp/myapp/static/favicon.ico;
        access_log off;
        log_not_found off;
    }

    location /static/ {
        alias /opt/djangoapp/myapp/static/;
    }

    location /media/ {
        alias /opt/djangoapp/myapp/media/;
    }

    location / {
        proxy_redirect off;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 600;
        proxy_pass http://unix:/opt/djangoapp/app.sock;
    }
}

The sample favicon.ico alias assumes you add a file there or remove the block. Django’s collectstatic layout may put admin assets under static/admin/... only.

Validate and reload Nginx:

1
2
3
sudo nginx -t
sudo systemctl enable --now nginx
sudo systemctl reload nginx

9. Permissions so Nginx can reach the app

Nginx (user nginx) must traverse /opt/djangoapp and read static files and the Unix socket. A common pattern:

1
2
3
4
5
6
7
sudo usermod -a -G appuser nginx
sudo chmod 710 /opt/djangoapp
sudo chmod 710 /opt/djangoapp/myapp
# Static tree readable
sudo mkdir -p /opt/djangoapp/myapp/media
sudo chown -R appuser:appuser /opt/djangoapp/myapp/media
sudo chmod -R u+rX,g+rX /opt/djangoapp/myapp/static /opt/djangoapp/myapp/media

Verify ownership: project files owned by appuser, socket appuser:nginx with 660.

10. Firewall (firewalld)

If firewalld is enabled, allow HTTP and HTTPS:

1
2
3
4
sudo systemctl enable --now firewalld
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

For a public site, terminate TLS at Nginx. On many servers, Let’s Encrypt via certbot is the simplest path:

1
2
sudo dnf install -y certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.example

Then set SECURE_PROXY_SSL_HEADER and USE_TLS/CSRF_TRUSTED_ORIGINS in Django as appropriate when everything sits behind HTTPS—see the Django deployment checklist.

Troubleshooting

SymptomThings to check
502 Bad Gateway from NginxIs myapp running? (systemctl status myapp). Socket path match? Permissions on /opt/djangoapp/app.sock?
Database connection errorspostgresql-14 active? Credentials in /etc/django/myapp.env? pg_hba.conf allows local md5/scram for 127.0.0.1?
Static files 404Run collectstatic. STATIC_ROOT matches Nginx alias. Permissions for nginx to read static/.
Works on IP, breaks on domainALLOWED_HOSTS must include the domain. DNS must point to this server.
Migrations not appliedRun python manage.py migrate with the same env vars as production.

Logs:

1
2
journalctl -u myapp -f
sudo tail -F /var/log/nginx/error.log

Security and maintenance checklist

  • Rotate DJANGO_SECRET_KEY only with a deliberate plan (it invalidates sessions); store it in /etc/django/myapp.env, mode 600.
  • Set DEBUG=0 and real ALLOWED_HOSTS before going live.
  • Keep gunicorn, Django, and OS packages updated (dnf update).
  • Use TLS, limit admin exposure (VPN, IP allowlist, or separate host), and configure backups for PostgreSQL.
  • Prefer fail2ban or similar if you expose SSH and admin endpoints to the internet.

Conclusion

You now have a typical Rocky Linux 9 / AlmaLinux 9 layout for Django: PostgreSQL for data, venv for Python dependencies, Gunicorn supervised by systemd on a Unix socket, and Nginx serving static assets and proxying dynamic requests. Extend this foundation with HTTPS, monitoring, centralized logging, and hardened SELinux policies when you move from a lab setup to production traffic.

If you want a containerized variant of the same stack, start from the Docker Compose + Django guide linked above.

comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy