Deploying Django Applications with Nginx and Gunicorn

This week I spent some time working through the Mozilla Developer Network’s Django Tutorial. One of the final chapters covers the process of deploying a Django application. To my disappointment, it only covered deployment to Heroku. I frequently see Nginx and Gunicorn mentioned in discussions about Django deployment, so wanted to try using these instead.

Although the setup covered in this post still isn’t ideal for production use, it’s hopefully enough to point you in the right direction. I took some shortcuts to prevent this post from being too long, but have left a summary of some suggested changes at the end of the post.

As a side note, the MDN Django tutorial was really good. If you’re interested in learning how to write Django apps, I’d recommend that tutorial rather than the one on the official Django website. However, this post only covers the deployment of Django apps, not the creation of them.

I should also mention that I tend to use CentOS as my server OS of choice. Command examples shown in this post were all executed on a CentOS 8 server.

Step 1: Creating a Python Virtual Environment

If you’re not familiar with Python virtual environments, they essentially allow you to have multiple Python environments running on the same machine. Each Python environment also has it’s own set of modules. This is useful if you’re running multiple Python apps on the same machine requiring different versions of the same module. E.g. one app may require Django 2 while the other requires Django 3.

In this case I probably don’t need a Python virtual environment, as I only run the one app, but it’s a good habit to get into regardless.

# Create the project directory
mkdir djangoproject

# Create the virtual environment
python3 -m venv ~/djangoproject/djangoenv

# Enable the virtual environment
source ~/djangoproject/djangoenv/bin/activate

# Upgrade PIP
pip install --upgrade pip

The third line in the set of commands above is where I enable the virtual environment I just created. So whenever I run python or pip from this point on, it’s running the binaries stored in ~/djangoproject/djangoenv/bin. Running “deactivate” would take me out of the virtual environment and back to using /bin/python3

Step 2: Installing Django

For the purposes of this overview, I’ve just installed Django and created a new Django Project without adding to it. This is good enough to test the basic deployment.

# Install Django
pip install django

# Initialize a new Django project
django-admin startproject djangoproject ~/djangoproject/

# Run the Django development server to test it worked
python ~/djangoproject/manage.py runserver

Now when I go to http://localhost:8000 I can see that the Django app is running:

Django success screen

Step 3: Installing and Testing Gunicorn

As mentioned earlier, the Django development server is not intended for production use. One common approach is to use the Gunicorn WSGI (web server gateway interface) server in it’s place.

# Install Gunicorn
pip install gunicorn

# Move into the Django project directory
cd ~/djangoproject

# Have Gunicorn listen on port 8000 and serve the Django project
gunicorn --bind localhost:8000 djangoproject.wsgi:application

The last command in the block above is telling Gunicorn to listen on port 8000. It will also translate and pass on the web requests to the Django app. http://localhost:8000 returns the same page as before, but this time served via Gunicorn rather than the Django development server.

Gunicorn sits between the web server and the Django application. Client requests for static files (images, CSS, JavaScript, etc.) can be dealt with by the web server or a CDN. Any requests for the dynamic Django content will be passed from the web server to Gunicorn.

Step 4: Running Gunicorn as a SystemD Service

If this was a production server, I wouldn’t want to have to manually restart the Gunicorn server if it crashed or after a system reboot. Thankfully, SystemD makes it really easy to create a service to do this for us.

The code block below shows the contents of my Gunicorn service file: /etc/systemd/system/gunicorn.service

[Unit]
Description=gunicorn daemon
After=network.target

[Service]
User=dean
Group=dean
Restart=on-failure
WorkingDirectory=/home/dean/djangoproject
ExecStart=/home/dean/djangoproject/djangoenv/bin/gunicorn --bind unix:/home/dean/djangoproject/gunicorn.sock djangoproject.wsgi:application

[Install]
WantedBy=multi-user.target

Notice that I’m running the service using my user account – “dean”. If this was a production server, I’d create a gunicorn account and use that instead. I would also move the Django project files out of my home directory.

Now that the service has been defined, it can be enabled (to start automatically after a reboot) and started:

sudo systemctl enable --now gunicorn.service

Step 5: Setting Up Nginx

To save some hassle, I’ve set SELinux to permissive mode at this point (setenfore 0). By default, SELinux doesn’t like the gunicorn.service trying to access files in my home directory. Given more time, I’d use the system audit logs to create an appropriate SELinux policy. That is a whole topic in itself.

The code block below shows the partial contents of my Nginx config file. I’ve added this “server” block as the first element of the “http” block in /etc/nginx/nginx.conf

...
server {
    listen 80;
    server_name localhost;

    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:/home/dean/djangoproject/gunicorn.sock;
    }
}
...

The screenshot below gives some more context of where this fits in the Nginx config file. There are also a few extra lines that I’ve commented out. The first just prevents requests for the favicon being logged. The next two are for getting Nginx to serve Django’s static files, but that’s not something I’ve covered in this post.

nginx config file contents

Nginx is really just a reverse proxy in this example. It passes all the requests over to Gunicorn.

All that’s left to do is enable the nginx service (sudo systemctl enable –now nginx), go to http://localhost:80 and see the same webpage that we saw earlier. Exciting stuff!

Web requests hit the server. Nginx forwards them on to Gunicorn. Gunicorn forwards them on to the Django project and back comes the HTTP response.

You’ll notice that the port has changed from 8000 to 80 because Nginx is listening on port 80. The previous examples used port 8000 as this is the default port that the Django development server listens on. However, as defined in the SystemD service file, Gunicorn is now using a Unix socket to communicate with Nginx rather than a TCP/IP port, so is no longer listening on port 8000. Because Nginx and Gunicorn are both running on the same machine there is no real need for them to use a network socket to communicate with each other. The Unix socket is more efficient for this.

Finishing Up

If, like me, you’re new to deploying Django web apps, hopefully this helps get you started. Initially it felt like a lot of work to get running, but looking back over this post there really wasn’t much to it.

At the start of the post also I mentioned that this setup still isn’t ideal for production environments. Below are a few of the potential issues with using this setup in production:

  • Django is not configured to serve static files. Nginx can be configured to do this or the files can be hosted and served from elsewhere
  • My home directory probably isn’t the best place to store the Django project files
  • The SystemD Gunicorn service could be running as a gunicorn user account with folder permissions changed accordingly (rather than running as my user)
  • Rather than disabling SELinux, extra time could be taken to create a custom policy for the Gunicorn service
  • Although databases weren’t covered in this post, Django should be reconfigured (djangoproject/settings.py) to use something other than SQLite
  • Serve over HTTPS
zabbix 5.0 screenshot

Installing Zabbix Using Containers

I recently had a bit of an ordeal trying to upgrade Zabbix to version 5.0 on a CentOS 7 server. This led to me installing Zabbix using containers. The problem was that Zabbix 5.0 requires a newer version of PHP than CentOS 7 ships with. Somehow I managed to miss that note before I started working through the upgrade.

It got me thinking “wouldn’t this be so much easier if Zabbix just came bundled with all it’s dependencies?” Then it struck me; that’s what containers do! After a quick search on Dockerhub I could see that Zabbix containers were available. I’d been wanting to upgrade that server to CentOS 8, so seemed like a good excuse for a re-install.

Full disclaimer – I’d never worked with containers before this. My setup probably isn’t ideal for production use, but it worked as a learning exercise.

Getting started with Podman (or Docker) to manage the Zabbix containers

First up, have Podman (or Docker, the commands should be the same) download copies of the container images from Docker Hub:

podman pull docker.io/library/mariadb:latest
podman pull docker.io/zabbix/zabbix-server-mysql:centos-5.0-latest
podman pull docker.io/zabbix/zabbix-web-apache-mysql:centos-5.0-latest

Create a Dockerfile for each container. These will be used to create a customised container image. You’ll obviously want to set the passwords to something more sensible than the ones in my example. You’ll also need to change the IP addresses to fit your environment. You can get more info on what each parameter does on the Dockerhub pages.

# ~/dockerfiles/mariadb/Dockerfile

FROM docker.io/library/mariadb:latest
ENV MYSQL_ROOT_PASSWORD=password

# ~/dockerfiles/zabbix-server/Dockerfile 

FROM docker.io/zabbix/zabbix-server-mysql:centos-5.0-latest 
ENV DB_SERVER_HOST=192.168.179.128 
ENV DB_SERVER_PORT=33306 
ENV MYSQL_ROOT_PASSWORD=root 
ENV MYSQL_USER=zabbix 
ENV MYSQL_PASSWORD=password
ENV MYSQL_DATABASE=zabbix 

# ~/dockerfiles/zabbix-web/Dockerfile

FROM docker.io/zabbix/zabbix-web-apache-mysql:centos-5.0-latest 
ENV ZBX_SERVER_HOST=192.168.179.128 
ENV ZBX_SERVER_PORT=10051 
ENV DB_SERVER_HOST=192.168.179.128 
ENV DB_SERVER_PORT=33306 
ENV MYSQL_USER=zabbix 
ENV MYSQL_PASSWORD=password
ENV MYSQL_DATABASE=zabbix 
ENV PHP_TZ="Europe/London" 
ENV ZBX_SERVER_NAME=zabbix 

Create the custom container images using the Dockerfiles:

podman build –t mariadb ~/dockerfiles/mariadb/ 
podman build –t zabbix-server ~/dockerfiles/zabbix-server 
podman build –t zabbix-web ~/dockerfiles/zabbix-web 

Run the containers, mapping the host ports to the container ports. Because I’m running in rootless mode, I can’t use port 80 or 3306 on the host. Instead I’m using ports 8080 and 33306. If you were running as root I don’t believe the default port mappings would be an issue.

podman run –d –p 33306:3306 localhost/mariadb 
podman run –d –p 10051:10051 localhost/zabbix-server 
podman run –d –p 8080:8080 localhost/zabbix-web 

All I had left to do at this point was add some firewall rules to allow outside access to Zabbix:

firewall-cmd --zone=public --add-port=8080/tcp --permanent
firewall-cmd --zone=public --add-port=10051/tcp --permanent
firewall-cmd --reload

And just like that, I had successfully finished installing Zabbix using containers!

A screenshot of the Zabbix 5.0 dashboard page after installing using containers.

Installing WordPress on CentOS 8

After recently upgrading my web server to CentOS 8 I decided that I wanted to replace my old static web page with a more intersting WordPress site.

Getting WordPress up and running on CentOS 8 was a fairly straight forward process however there were a couple of small things that tripped me up along the way (I’m mainly looking at you, SELinux!)

In this post I have provided step by step instructions on installing WordPress on CentOS 8. This includes steps on keeping SELinux happy and using LetsEncrypt / Certbot to serve the site over HTTPS.

Note that many of the commands in the guide will require root privileges. You’ll either need to run them while logged in as the root user or by prepending the commands with sudo.

Installing the Required Packages

There are a few packages required to get WordPress up and running on CentOS 8. You’ll need to install the following:

dnf install php-mysqlnd php-fpm php-gd mariadb-server httpd mod_ssl tar curl php-json -y

Adding Firewall Rules

In order for people to access your WordPress site, you’ll need to add some firewall rules. These rules are allowing access to port 80 and 443 from the outside world.

You will want to use the “–permanent” flag to ensure that these rules survive any server reboots. Once the rules have been added you will then need to reload the firewall.

firewall-cmd --zone=public --add-service=http --permanent
firewall-cmd --zone=public --add-service=https --permanent
firewall-cmd --reload

Note: I don’t want to spend too much time discussing firewall rules in this post, however if you don’t want people trying to access your site before it’s properly configured, I have another blog post that you might find useful. In it, I go over how you can configure the firewall to only allow access to the site from your local machine until you’re ready to make it publicly available.

Enabling HTTPD and MariaDB

Next, you’re going to start the Apache (httpd) and MariaDB services. You will also enable them to start automatically after a system reboot.

systemctl enable --now httpd
systemctl enable --now mariadb

With the services enabled (and previously mentioned firewall rules applied), you should now be able to navigate to the URL or IP address of your web server in the browser and see the default Apache/CentOS welcome page.

Getting the Database Ready

Now that the MariaDB service is up and running you can run a handy tool which helps to make the default config more secure. This tool is interactive and will prompt you to set a password for the root SQL user as well as make some changes to default security settings. In most cases, you’ll want to go with the suggestions it makes. Make sure to keep a note of the password!

mysql_secure_installation

Once the MariaDB server has been secured, it’s time to create the database and SQL user login that will be used by WordPress. You’ll also give that user full control over the wordpress database you create.

You’ll need to connect to mysql using the root password that you set in the previous step. When creating the SQL login for WordPress, remember to replace ‘your_password‘ with a secure password of your own.

mysql -u root -p
Enter password:

CREATE DATABASE wordpress;
CREATE USER 'wordpress'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON wordpress.* TO 'wordpress'@'localhost';
EXIT

Downloading WordPress

That’s most of the ground work done. Now it’s time to start installing WordPress on CentOS 8. Download a copy of WordPress and move it into the Apache document root:

wget https://wordpress.org/latest.tar.gz
tar -xf latest.tar.gz

cp wordpress/* /var/www/html/

chown -R apache:apache /var/www/html/
chmod -R g+w /var/www/html/
usermod -aG apache your_server_username

There are a few things going on in the commands above. The first two lines have you download the latest copy of WordPress and extract the contents to your current directory. You then copy the extracted contents to the /var/www/html/ directory, which is Apache’s default document root.

Once copied, you then have some permissions to tidy up. First, you change the owner of the /var/www/html/ directory and all of it’s contents to the apache user and the apache user group. You then give the apache group the ability to edit the files. Finally, you add your own account to the apache group. This allows you to more easily work with the contents of the /var/www/html/ directory. You will likely need to log out and back into your server before the group membership changes take effect.

Keeping SELinux Happy

The following command applies an SELinux label to the /var/www/html/ directory and all of it’s contents. This label tells SELinux that Apache is allowed to read and alter the contents.

chcon -R -t httpd_sys_rw_content_t /var/www/html/

While on the topic of SELinux, there is one other change that you will likely want to make. The following command tells SELinux that the Apache process should be allowed to connect to the network. This is to allow the WordPress dashboard to check for updates, as well as allow you to view WordPress themes and plugins from the dashboard.

setsebool -P httpd_can_network_connect 1

Enabling HTTPS and Redirection

The remaining steps focus on setting your site up to serve over HTTPS. You will also make a change to Apache’s httpd.conf file which allows WordPress to use pretty URLs.

This allows you to change the default URLs from something like https://yoursite.com/?p=1 to something nicer, like https://yoursite.com/blog/blog-post-title which you can later manage from the WordPress dashboard (Settings -> Permalink Settings).

The .htaccess File

First up, create the .htaccess file in the /var/www/html/ directory and add some rules which redirect all http requests to https:

touch /var/www/html/.htaccess

# Add the following to the .htaccess file using your preferred text editor:

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{HTTPS} off
  RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</IfModule>

Updating httpd.conf

Next you make the change to Apache’s httpd.conf file which allows pretty URLs to be used. Use your preferred text editor to open /etc/httpd/conf/httpd.conf and scroll down until you find a section like the example below. It wont look exactly like this, as I’ve stripped out the comments for brevity. You then need to make sure that the AllowOverride option is set to “All“.

<Directory "/var/www/html">
    Options Indexes FollowSymLinks
    AllowOverride All
    Require all granted
</Directory>

Save your changes and restart Apache

systemctl restart httpd

Installing a SSL Certificate

In this final step, you’ll use LetsEncrypt and Certbot to enable HTTPS.

First, you’ll use Certbot to request a certificate from LetsEncrypt. You will need to remember to change the email address and domain in the following command to match your own:

certbot certonly --webroot -w /var/www/html/ --renew-by-default --email your@email.com --text --agree-tos  -d www.yourdomain.com

At this point Certbot will have placed several files on your server. You now need to tell Apache where to find them

# Use your preferred text editor to edit /etc/httpd/conf.d/ssl.conf
# Locate the following variables and update their values as follows
# Remember to replace www.yourdomain.com with your actual domain

SSLCertificateFile /etc/letsencrypt/live/www.yourdomain.com/cert.pem
SSLCertificateKeyFile /etc/letsencrypt/live/www.yourdomain.com/privkey.pem
SSLCertificateChainFile /etc/letsencrypt/live/www.yourdomain.com/fullchain.pem

Finally, you should set up a cron job (scheduled task) to automatically renew the certificate. There are various ways to do this, but this example uses the /etc/crontab file. This causes Certbot to check your certificate each day and renew if the certificate is due to expire within the next 30 days.

# Use your preferred text editor to edit /etc/crontab
# Add the following to the bottom of the file

0 0 * * 0 root /usr/bin/certbot renew >> /var/log/certbot-renew.log

Finishing Up

And that’s it! You should now have finished installing WordPress on CentOS 8. All that’s left to do is navigate to your server’s URL or IP address in the browser and follow the WordPress set up steps.

Additional Note – Upgrading to PHP 7.4

As mentioned by Dan in the comments, I should have upgraded to PHP 7.4 as CentOS 8 uses 7.2 by default. The WordPress admin dashboard will warn you if your server is running a PHP version lower than 7.4

Upgrading PHP on CentOS 8 Stream is as easy as this:

dnf module switch-to php:7.4

After the above command has finished running, you can check the version using:

php-fpm --version