Setting up CentOS 8 / PostgreSQL 12 / Django 3.1

WHO CARES? CENTOS IS DEAD TO ME

Because of Redhat's killing off the promised long term support of CentOS (and breaking their original promise of not messing with CentOS), I have to find a LTS distro. I hope this document is still helpful, but I'm not going to update it anymore.

This guide starts after the first boot. I used the CentOS 8 minimal ISO and accepted all the defaults during installation. I assume you're already familiar with Django (this is not a Hello World Django tutorial).

Groundwork

Run the following commands as root, or prepend everything with sudo if that's more your speed.

# Enable and start sshd
systemctl enable sshd --now
# Update the system
dnf check-update
dnf update -y
# Restart
reboot
 
# Enable the EPEL repo
dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm
# Enable the PowerTools repo
dnf config-manager --set-enabled PowerTools
# Install Python, Git, and Nginx (Postgres is more complicated). NOTE: openldap-devel is optional if you don't need it for your project.
dnf install -y python38 python38-devel git nginx gcc openldap-devel
 
# Download/enable the official Postgres repo
dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm
# Disable the CentOS-provided Postgres 
dnf -qy module disable postgresql
# Install PostgreSQL, contrib modules, development files, and PostGIS support (PostGIS support is optional)
dnf install -y postgresql12-server postgresql12-contrib postgresql12-devel postgis25_12
# Initialize the database
/usr/pgsql-12/bin/postgresql-12-setup initdb
# Enable and run Postgres and Nginx
systemctl enable postgresql-12 nginx --now

Now some Django-specific stuff

# Make a user.  Your Django app will run as this user.
adduser --system --home=/opt/django --create-home django
# Install virtualenv
dnf -y install python3-virtualenv
 
# You can use su to switch to the django user now if you're inclined.
# Create new virtual environment for your Django app
virtualenv /opt/django/venv
# Activate your virtual environment
source /opt/django/venv/bin/activate

Django / Gunicorn / Nginx Proof-of-concept

At this point, we take a break to set up a starter Django project to (re)familiarize ourselves with the Nginx/Gunicorn/Django stack. We can trash the starter project when we're done.

# Install Django and gunicorn
pip install Django gunicorn
# change to django directory
cd /opt/django
# Create a test app
django-admin startproject djangotest
# change to test project directory
cd djangotest
# Run migrations
./manage.py migrate
# *TEMPORARILY* disable the firewall and SELinux so we can see if the Django server works
systemctl stop firewalld  && setenforce 0
# Run Django's built-in server (figure out what your external IP address is with ``ip addr``)
 ./manage.py runserver 0.0.0.0:8000

Now go to http://192.168.10.205:8000 (change the IP address obviously). You should get a DisallowedHost at / error. This is normal at this point, but it proves that Django is working.

Edit the djangotest/settings.py file to add the IP address to the ALLOWED_HOSTS list.

Try running the Django test project through gunicorn:

gunicorn djangotest.wsgi -b 192.168.10.205

You should be able to get to the Django test project in your browser. This time it's gunicorn serving up the site instead of Django's built-in server.

Now we'll add Nginx into the mix. First, make sure Nginx is accessible by going to http://192.168.10.205 (default port of 80). You should see the default Nginx Redhat splash page.

Create a file called /etc/nginx/conf.d/test.conf and add the following to it:

server {
    listen 80;
    server_name 192.168.10.205;

    location / {
        proxy_set_header Host $http_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;
        # This is the path to the UNIX socket that gunicorn listens to requests on
        proxy_pass http://unix:/opt/django/run/socket;
    }
}

Now we restart Nginx and also run gunicorn so we can test our new configuration:

# Restart nginx to apply the config
systemctl restart nginx
# Create a folder for gunicorn's socket
mkdir /opt/django/run
# Give "group" and "others" read/execute permission on the /opt/django folder so that nginx can access the socket file
chmod go+rx /opt/django /opt/django/run
# Change to the test folder
cd /opt/django/djangotest
gunicorn  --workers 3 --bind unix:/opt/django/run/socket djangotest.wsgi

Now visit the website and you should see the default Django page. After we confirm this all works, we can return to getting our real Django site working.

The Real Site

Here are some assumptions I'm making:

  • We're going to use the host/md5 method to authenticate the user
  • The Django database user will be called djangouser
  • The Django database user will be called djangodb

Postgres

First, let's address Postgres' configuration:

# Switch to the postgres user.
su - postgres
# Create the database user for your Django site.  When prompted about making the new user a superuser, I recommend answering yes.  You can remove superuser permissions later.
createuser --interactive --pwprompt
# Create the database, assigning the owner to our djangodb user
createdb -O djangouser djangodb

Edit the /var/lib/pgsql/12/data/pg_hba.conf file. Underneath the local all all peer line add host djangodb djangouser 127.0.0.1/32 md5. Comment out the two lines that start with host all. In the end your file should look something like this:

# TYPE  DATABASE        USER            ADDRESS                 METHOD

# "local" is for Unix domain socket connections only
local   all             all                                     peer
host    djangodb        djangouser   127.0.0.1/32           md5
# IPv4 local connections:
#host    all             all             127.0.0.1/32            ident
# IPv6 local connections:
#host    all             all             ::1/128                 ident
# Allow replication connections from localhost, by a user with the
# replication privilege.
local   replication     all                                     peer
host    replication     all             127.0.0.1/32            ident
host    replication     all             ::1/128                 ident

Reload Postgres to apply the changes, and try to connect with your Django database user account.

# Reload Postgres
systemctl reload postgresql-12.service
# Try to log in
psql -U djangouser -h 127.0.0.1 djangodb

Django

Assumptions:

  • Your Django project is called myproject. It will live in /opt/django/myproject.
  • You have unique Django settings modules for production, development, etc. located in a module called main.
  • Your production settings module is found in main.settings_for_prod_centos8 (so the full path will be /opt/django/myproject/main/settings_for_prod_centos8.py).

Time to get our real Django app running.

# Change to Django directory
cd /opt/django
# Clone your project's repo
git clone https://github/yourname/myproject
# We need pg_config so that we can build PostgreSQL support for Django (the pscyopg2 module)
export PATH=/usr/pgsql-12/bin:$PATH
# Install your requirements
pip install -r myproject/requirements.txt
# Change into your project directory
cd myproject

Now create your main/settings_for_prod_centos8.py and customize it with your database settings. The DATABASES section will look like this:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'djangoudb',
        'USER': 'djangouser',
        'PASSWORD': 'whatever_you_set_earlier',
        'HOST': '127.0.0.1',
        'PORT': 5432,
    }
}

Now we can try running Django's built-in server with your real project.

# Create the static folder
mkdir /opt/django/static
# Create the media folder
mkdir /opt/django/media
# Set DJANGO_SETTINGS_MODULE to your project'
export DJANGO_SETTINGS_MODULE=main.settings_for_prod_centos8
# Run the migrations with the ''--plan'' parameter to see what Django will do to the database.
./manage.py migrate --plan
# If you saw no errors, run the migrations for real
./manage.py migrate
# Run collectstatic
./manage.py collectstatic
# Test your app with the Django's runserver command
./manage.py runserver 0.0.0.0:8000

If you can access your app, congratulations! You did it! But you're not finished yet. Let's get Nginx working with our Django app.

Nginx

# Set the owner of the directory to django
chown django:django /opt/django/run
# Make a copy of our Nginx test.conf file
cp /etc/nginx/conf.d/test.conf /etc/nginx/conf.d/django.conf
# Rename the test.conf file so Nginx doesn't load it (alternatively, delete it)
mv /etc/nginx/conf.d/test.conf /etc/nginx/conf.d/test.unused

Update the /etc/nginx/conf.d/django.conf file to include the /static/ files config and to change the path to the socket:

server {
    listen 80;
    server_name 192.168.10.205;
 
    location /static/ {
        root /opt/django;
    }
 
    location / {
        proxy_set_header Host $http_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_pass http://unix:/opt/django/run/socket;
    }
}

Try running Nginx and Django with this configuration:

# Change to the folder where your app's manage.py lives before running the following commands.
cd /opt/django/myproject
# Restart Nginx
systemctl restart nginx
# Run Gunicorn 
gunicorn  --workers 3 --bind unix:/opt/django/run/socket main.wsgi

The site should now load up and work! If it doesn't, try to do it better next time. Stop the gunicorn process.

Systemd Service File

To make Gunicorn/Django start automatically we have to create a Systemd service definition file /etc/systemd/system/django-gunicorn.service.

[Unit]
Description=Django/Gunicorn service
After=network.target

[Service]
User=django
Group=nginx
WorkingDirectory=/opt/django/myproject
ExecStart=/opt/django/venv/bin/gunicorn --workers 3 --bind unix:/opt/django/run/socket main.wsgi
Environment=DJANGO_SETTINGS_MODULE=main.settings_for_prod_centos8

[Install]
WantedBy=multi-user.target

Start the service:

systemctl enable django-gunicorn.service --now

And your site should be available! You did it! But you're NOT FINISHED. Time to secure everything!

Securing

If you were to simply turn SELinux and the firewall back on, you wouldn't be able to access your site because:

  • Nginx won't be allowed to read from or write to the socket file
  • Firewalld blocks inbound port 80 and 443
# Set the selinux context of the run (socket) directory
chcon -R -t httpd_sys_rw_content_t /opt/django/run
# Re-enable the firewall so we can configure it
systemctl start firewalld
# Allow port 80 and 443 through the firewall
firewall-cmd --zone=public --permanent --add-service=http
firewall-cmd --zone=public --permanent --add-service=https
firewall-cmd --reload
# Re-enable selinux
setenforce 1
 
# If you were running some commands as root, make sure to reset the permissions on the project folder to the django user.
chown -Rv django:django /opt/django/myproject
 
# Remove "group" and "other" permissions from the project folder.  We don't want others to see our settings file!
chmod go-rx /opt/django/myproject
 
# If you want to check permissions, run the following:
sudo -u nginx cat /opt/django/myproject/main/settings_for_prod_centos8.py
# the above command should fail with a "Permission denied" error.
 
# Remove the PostgreSQL superuser permission from the django database user:
su - postgres
psql -d djangodb -c "ALTER USER djangouser WITH NOSUPERUSER;"

Your site should be accessible and somewhat secure. Somewhat? Yeah, you didn't secure SSH, you didn't install fail2ban, you didn't enable TLS, and you didn't set up any backups. You fool. You moron. Go learn how to do that somewhere else.

Final Step

Reboot. If your site comes up and works without any further intervention, congratulations! You did it!


User Tools