This page looks best with JavaScript enabled

Django Project in Production

 ·  🎃 kr0m

In this article, we will explain how to deploy a Django project so that it is ready for production. We will use HaProxy as a load balancer, Nginx as a web server for static content, and finally ASGI/Daphne as a Python server. In addition, we will follow a series of steps such as disabling debug, defining paths, enabling and forcing SSL, caching database connections, and configuring web error reporting. As a final step, we will configure Git as a version control system and program a deployment script to upload changes.

Before we begin, it is recommended to read the previous articles on Django as they are the previous steps to this article:


This article is composed of several parts:


PostgreSQL Server

First of all, let’s install the PostgreSQL server and check the available versions:

pkg search postgresql|grep '\-server'

postgresql10-server-10.15   PostgreSQL is the most advanced open-source database available anywhere  
postgresql11-server-11.10   PostgreSQL is the most advanced open-source database available anywhere  
postgresql12-server-12.5    PostgreSQL is the most advanced open-source database available anywhere  
postgresql13-server-13.1_1  PostgreSQL is the most advanced open-source database available anywhere  
postgresql95-server-9.5.24  PostgreSQL is the most advanced open-source database available anywhere  
postgresql96-server-9.6.20  PostgreSQL is the most advanced open-source database available anywhere

Install the desired version:

pkg install postgresql13-server

Configure PostgreSQL to start at system boot:

sysrc postgresql_enable="yes"

Initialize the database:

/usr/local/etc/rc.d/postgresql initdb

Start the service:

service postgresql start

By default, PostgreSQL uses the “peer authentication” authentication scheme for local connections, which basically means that if a SO user’s name matches a PostgreSQL user’s name, this user can access the PostgreSQL cli without authentication.

su - postgres
psql

Create the web application access user:

CREATE USER rxwod_user WITH PASSWORD 'PASSWORD';  
ALTER ROLE rxwod_user SET client_encoding TO 'utf8';  
ALTER ROLE rxwod_user SET default_transaction_isolation TO 'read committed';  
ALTER ROLE rxwod_user SET timezone TO 'UTC+1';

Create the database indicating the owner:

CREATE DATABASE rxwod WITH OWNER rxwod_user;

List the databases:

\l
                              List of databases  
   Name    |   Owner    | Encoding | Collate | Ctype |   Access privileges     
-----------+------------+----------+---------+-------+-----------------------  
 postgres  | postgres   | UTF8     | C       | C     |   
 rxwod     | rxwod_user | UTF8     | C       | C     |   
 template0 | postgres   | UTF8     | C       | C     | =c/postgres          +  
           |            |          |         |       | postgres=CTc/postgres  
 template1 | postgres   | UTF8     | C       | C     | =c/postgres          +  
           |            |          |         |       | postgres=CTc/postgres  
(4 rows)

Assign the necessary permissions and exit:

GRANT ALL PRIVILEGES ON DATABASE rxwod TO rxwod_user;  
\q  
exit

Check the access:

psql -U rxwod_user rxwod

rxwod=> \conninfo
You are connected to database "rxwod" as user "rxwod_user" via socket in "/tmp" at port "5432".

Dump the database from our PC:

su - postgres
pg_dump rxwod > /tmp/rxwod.psql

Upload the dump to the server:

scp /tmp/rxwod.psql RxWod:/tmp/rxwod.psql

We load the dump onto the server:

su - postgres
psql rxwod < /tmp/rxwod.psql

We check that it has created the necessary tables:

psql

postgres=# \c rxwod  
rxwod=# \dt  
                    List of relations  
 Schema |            Name            | Type  |   Owner      
--------+----------------------------+-------+------------  
 public | auth_group                 | table | rxwod_user  
 public | auth_group_permissions     | table | rxwod_user  
 public | auth_permission            | table | rxwod_user  
 public | auth_user                  | table | rxwod_user  
 public | auth_user_groups           | table | rxwod_user  
 public | auth_user_user_permissions | table | rxwod_user  
 public | django_admin_log           | table | rxwod_user  
 public | django_content_type        | table | rxwod_user  
 public | django_migrations          | table | rxwod_user  
 public | django_session             | table | rxwod_user  
 public | rxWod_exercise             | table | rxwod_user  
 public | rxWod_routine              | table | rxwod_user  
(12 rows)  
  
exit

Another way to access the PostgreSQL shell and check that everything is in order is through the dbshell command, which will use the credentials indicated in the project configuration:

python manage.py dbshell

rxwod=> select * from "rxWod_routine";  
 id |            date            |  exercise_ids   | rounds |     percentages     | user_id   
----+----------------------------+-----------------+--------+---------------------+---------  
  1 | 2020-12-20 22:00:24.524-01 | 1,2,3,4,5,6,7,8 |      4 | 50,50,0,0,0,0,0,0,0 |       1  
  2 | 2020-12-20 22:00:37.701-01 | 1,2,3,4,5,6,7,8 |      2 | 50,50,0,0,0,0,0,0,0 |       1  
  3 | 2020-12-20 22:00:46.758-01 | 1,2,3,4,5,6,7,8 |      1 | 50,50,0,0,0,0,0,0,0 |       1  
  4 | 2020-12-23 23:36:33.569-01 | 1,2,3,4,5,6,7,8 |      4 | 50,50,0,0,0,0,0,0,0 |      24  
(4 rows)

We delete the dump both on the server and on our PC:

rm /tmp/rxwod.psql


Git Repository

The next step is to set up a Git repository, so we can version the code and collaborate with other programmers more easily. When using Git, we must configure the files to exclude to avoid leaks of sensitive information.

We create the repository in Gitolite , which will be called RxWodProject.

We install the Git client on our PC:

pkg install git

We perform the initial configuration:

git config –global user.name “Kr0m”
git config –global user.email kr0m@alfaexploit.com

We externalize the file that will contain the SECRET_KEY of the project so that we can commit the settings.py file but ignore the secret_key file:

cd /home/kr0m/rxWod/rxWodProject
vi rxWodProject/settings.py

secret_key_path = str(Path(__file__).resolve().parent) + '/secret_key.txt'
with open(secret_key_path) as f:
    SECRET_KEY = f.read().strip()
vi rxWodProject/secret_key.txt
SECRET_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXX'

We initialize the repository:

cd /home/kr0m/rxWod/rxWodProject
git init

We will see the following message:

hint: Using 'master' as the name for the initial branch. This default branch name  
hint: is subject to change. To configure the initial branch name to use in all  
hint: of your new repositories, which will suppress this warning, call:  
hint:   
hint:  git config --global init.defaultBranch <name>  
hint:   
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and  
hint: 'development'. The just-created branch can be renamed via this command:  
hint:   
hint:  git branch -m <name>  
Initialized empty Git repository in /usr/home/kr0m/rxWod/rxWodProject/.git/

We configure Git:

git config pull.rebase false

We configure the files to ignore:

vi .gitignore

rxWodProject/secret_key.txt
rxWodProject/database_auth.txt
rxWodProject/email_auth.txt
rxWod/migrations
usersAuth/migrations
db.sqlite3
node_modules
yarn-error.log
assets/bundles/*
assets/videos/*
webpack-stats.json
django.mo
djangojs.mo
__pycache__/
*.core
production_enabled

We add the origin of the repository:

git remote add origin gitolite@192.168.69.5 :RxWodProject
git add .
git commit -m “Initial commit”
git push origin master

We configure Git to use the master branch:

vi .git/config

[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[remote "origin"]
        url = gitolite@192.168.69.5:RxWodProject
        fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
        remote = origin
        merge = refs/heads/master

We enable forwardagent when accessing the server. Remember that if someone else has access to this host, forwarding the agent can be dangerous. In that case, we should allow the key of the web server itself in the Git repository.

vi .ssh/config
Host RxWod
  HostName 192.168.69.12
  User kr0m
  Port 22
  ForwardAgent yes

We install the Git client on the server:

pkg install git

We install Yarn on the server:

pkg install npm
npm install -g yarn

We perform the initial Git configuration on the server (as a regular user):

git config –global user.name “Kr0m RxWod-Server”
git config –global user.email kr0m@alfaexploit.com

We create the Django project and clone the repository:

python3.7 -m venv rxWod
cd rxWod
git clone gitolite@192.168.69.5 :RxWodProject

We install its dependencies. There are some that are problematic through pip, so we install them globally:

pkg install py37-tkinter py37-sqlite3

The rest through pip:

source bin/activate
/usr/home/kr0m/rxWod/bin/python3.7 -m pip install –upgrade pip
pip install wheel
pip install -r RxWodProject/requirements.txt

If we notice, the repository is called RxWodProject, but in previous articles it was called rxWodProject. The path was: /home/kr0m/rxWod/rxWodProject, now it is: /home/kr0m/rxWod/RxWodProject.

This discrepancy in the names is due to the fact that to write the article I needed to create a new repository but I don’t want to lose the changes from the original repository since it is where I have the rxwod application practically finished. To avoid confusion and to keep the paths from changing, we rename the directory. Internally, the RxWodProject repository will continue to be used, but all paths will be seen as rxWodProject.

mv RxWodProject rxWodProject
cd rxWodProject

We install the JS dependencies:

yarn install

We upload the credential files to the server:

scp /home/kr0m/rxWod/rxWodProject/rxWodProject/secret_key.txt RxWod:/home/kr0m/rxWod/rxWodProject/rxWodProject/secret_key.txt
scp /home/kr0m/rxWod/rxWodProject/rxWodProject/database_auth.txt RxWod:/home/kr0m/rxWod/rxWodProject/rxWodProject/database_auth.txt
scp /home/kr0m/rxWod/rxWodProject/rxWodProject/email_auth.txt RxWod:/home/kr0m/rxWod/rxWodProject/rxWodProject/email_auth.txt

We create the directory for the bundles:

mkdir assets/bundles/

We execute the possible pending migrations in the database:

python manage.py makemigrations
python manage.py migrate

We copy the development WebPack configuration file to production:

cp webpack.dev.config.js webpack.prod.config.js

We change the mode parameter to production:

vi webpack.prod.config.js

module.exports = {
    mode: "production",

We compile the WebPack bundles:

yarn prod-build

We start the server:

python manage.py runserver

We access the server through an Ssh tunnel to check that it works correctly:

ssh -L 8000:localhost:8000 -A -g RxWod

We point our browser to:
http://localhost:8000

In the console where we have the server started, we will see:

[15/Mar/2021 22:03:39] "GET / HTTP/1.1" 302 0  
[15/Mar/2021 22:03:39] "GET /accounts/login/?next=/ HTTP/1.1" 200 591  
[15/Mar/2021 22:03:40] "GET /accounts/login/?next=/ HTTP/1.1" 200 591

If we log in, we will see the routine:

As we can see, the application works correctly on the new server, we stop the development server (Ctrl+c).


Python/Daphne Server

A Django project requires several parts to go into production, the first is a load balancer that will distribute requests among our servers, allowing us to scale horizontally, the second is a web server that will serve static content and forward project requests to the Python server, in our case, Daphne.

The scheme would be as follows:

Django comes with a built-in web server, but we must keep in mind that this server is only for use in the testing phase. On the Django website , they strongly advise against using it as a server in production:

Don’t use this server in anything resembling a production environment. It’s intended only for use while developing. (We’re in the business of making Web frameworks, not Web servers.)

On the Django website , we are warned that having our code in the web server’s document root is a terrible idea since it increases the chances of the app’s source code being viewed by an attacker.

It’s not a good idea to put any of this Python code within your Web server’s document root, because it risks the possibility that people may be able to view your code over the Web. That’s not good for security.
Put your code in some directory outside of the document root, such as /home/mycode.

We can choose from several options for web servers, but the two most popular are Apache and Nginx. These will be responsible for serving static content and sending requests that our code must handle to a WSGI/ASGI server.

Django currently supports two interfaces: WSGI and ASGI.

  • WSGI is the Python standard for communicating web servers with applications, but it only supports synchronous code.

    • Gunicorn : Server written entirely in Python, easily installable via pip.
    • uWSGI : Fast and sysadmin-friendly server, written in C.
    • mod_wsgi : Apache module that allows us to run Python code.
  • ASGI is the new asynchronous standard that allows Django to use Python/Django’s asynchronous features.

    • Daphne : Server written in Python and maintained by the Django team, acts as an ASGI server reference.
    • Hypercorn : Server with support for HTTP/1, HTTP/2, and HTTP/3.
    • Uvicorn : Server focused on speed.

WSGI has limitations in terms of concurrency. For example, if our project queries the database and it takes X time to respond, the web server does not attend to new requests until the first one has finished processing.

With ASGI, requests waiting for a response from the database remain on standby and new requests are attended to. When a response is received from the database, the pending request is processed.

ASGI provides a series of additional advantages for applications that require a persistent connection, such as chats. It maintains open bidirectional communication channels (websockets). However, to use these functionalities, it is not enough to use an ASGI server. We also need to program our code with the appropriate libraries.

ASGI servers are capable of managing both traditional WSGI requests and ASGI requests. Therefore, we will set up an ASGI server, and if we decide to use asynchronous functionalities later on, we can do so without any problems.

The processing scheme for WSGI requests is as follows:

With ASGI, we can see that in addition to WSGI processing, we have the WebSockets and channels part:

In this article, we will use Daphne as it is the standard server.

We install the dependencies:

pkg install rust

We install Daphne:

cd rxWod
source bin/activate
cd rxWodProject/
pip install daphne

We define the domain to which our application should respond:

vi rxWodProject/settings.py

ALLOWED_HOSTS = rxwod.alfaexploit.com

We configure the DNS so that rxwod.alfaexploit.com resolves to the server’s IP address.

We manually start Daphne to check that it works correctly:

cd /home/kr0m/rxWod/rxWodProject
daphne rxWodProject.asgi:application

2021-03-16 22:18:21,101 INFO     Starting server at tcp:port=8000:interface=127.0.0.1  
2021-03-16 22:18:21,102 INFO     HTTP/2 support not enabled (install the http2 and tls Twisted extras)  
2021-03-16 22:18:21,102 INFO     Configuring endpoint tcp:port=8000:interface=127.0.0.1  
2021-03-16 22:18:21,103 INFO     Listening on TCP address 192.168.69.12:8000

If necessary, we apply the following filtering rules:

$cmd 00806 allow tcp from PC_IP to SERVER_IP 8000 in via $wanif  
$cmd 00806 allow tcp from SERVER_IP 8000 to PC_IP out via $wanif

We can try to access to verify that it works:
http://rxwod.alfaexploit.com:8000/

As we can see, it works but the static content does not load because it must be served by Nginx:

To demonize Daphne, we will use the FreeBSD RC system:

vi /usr/local/etc/rc.d/daphne

#! /bin/sh
#
# $FreeBSD$
#

# PROVIDE: daphne
# REQUIRE: DAEMON
# KEYWORD: shutdown 

. /etc/rc.subr

name="daphne"
rcvar="${name}_enable"
extra_commands="status"

start_cmd="${name}_start"
stop_cmd="${name}_stop"
status_cmd="${name}_status"

daphne_start(){
    echo "Starting service: ${name}"
    chdir /home/kr0m/rxWod/rxWodProject
    /usr/sbin/daemon -S -p /var/run/${name}.pid -T daphne -u kr0m /home/kr0m/rxWod/bin/daphne rxWodProject.asgi:application --proxy-headers
}

daphne_stop(){
    if [ -f /var/run/${name}.pid ]; then
        echo "Stopping service: ${name}"
        kill -s INT $(cat /var/run/${name}.pid)
        sleep 3
    else
        echo "It appears ${name} is not running."
    fi
}

daphne_status(){
    if [ -f /var/run/${name}.pid ]; then
        echo "${name} running with PID: $(cat /var/run/${name}.pid)"
    else
        echo "It appears ${name} is not running."
    fi
}


load_rc_config ${name}
run_rc_command "$1"

NOTE: In the RC script, we have added the --proxy-headers parameter so that Daphne reads the headers that the Nginx we are going to mount a little later in this article will send it.

We assign permissions and owner:

chmod 555 /usr/local/etc/rc.d/daphne
chown root:wheel /usr/local/etc/rc.d/daphne

We enable the service and start it:

sysrc daphne_enable="YES"
service daphne start

We check that it has started:

sockstat -sv|grep 8000

kr0m     python3.7  726   12 tcp4   192.168.69.12:8000    *:*                                LISTEN

If any problem arises, we can debug Daphne by starting it manually with verbose: 2

(rxWod) rxWod $ ~/rxWod/rxWodProject> /home/kr0m/rxWod/bin/daphne -v 2 rxWodProject.asgi:application --proxy-headers

And consulting the generated logs:

tail -f /var/log/messages


Nginx

The next step is to configure Nginx to serve our static content and forward project requests to Daphne.
The static content will be generated using Webpack and with the Django collectstatic command, we will copy said content to the Nginx DocRoot.

In Django, we must define some parameters:

  • STATIC_URL: Url from which all our static content will hang, for example, if we want to access an image, we will access http://DOMAIN_NAME/STATIC_URL/file.png
  • STATICFILES_DIRS: Directory where the static content generated by WebPack is located, when the collectstatic command is executed, the files in this directory will be read and copied to the path configured in STATIC_ROOT.
  • STATIC_ROOT: Directory where all the static content of the project will be copied when the collectstatic command is executed, this content will be available under the Url: http://DOMAIN_NAME/STATIC_URL/, this step is only necessary when we pass our project to production and use some web server like Apache/Nginx. If we are using the development server on our PC, it will not be necessary to execute the collectstatic command since this content will be served directly from the STATICFILES_DIRS directory.
vi rxWodProject/settings.py
STATIC_URL = '/static/'
STATICFILES_DIRS = (
    os.path.join(BASE_DIR, 'assets/bundles/'),
)
STATIC_ROOT = '/usr/local/www/rxWod/static/'

We install Nginx and start it:

pkg install nginx

We enable and start the service:

sysrc nginx_enable="yes"
service nginx start

We create the directory where the static content will reside:

mkdir -p /usr/local/www/rxWod/static/
chown -R kr0m:kr0m /usr/local/www/rxWod/static/

We copy all the files our project needs:

python manage.py collectstatic

148 static files copied to '/usr/local/www/rxWod/static'

We check that they have been copied correctly:

ls -la /usr/local/www/rxWod/static

total 189  
drwxr-xr-x  4 kr0m  kr0m       10 Mar 16 22:37 .  
drwxr-xr-x  3 root  wheel       3 Mar 16 22:36 ..  
drwxr-xr-x  6 kr0m  kr0m        6 Mar 16 22:37 admin  
-rw-r--r--  1 kr0m  kr0m       57 Mar 16 22:37 base-c4b6e89240744702871f.js  
drwxr-xr-x  5 kr0m  kr0m        5 Mar 16 22:37 debug_toolbar  
-rw-r--r--  1 kr0m  kr0m    16735 Mar 16 22:37 django-logo-positive.png  
-rw-r--r--  1 kr0m  kr0m      348 Mar 16 22:37 index-c4b6e89240744702871f.js  
-rw-r--r--  1 kr0m  kr0m   161364 Mar 16 22:37 minimal-c4b6e89240744702871f.css  
-rw-r--r--  1 kr0m  kr0m   173306 Mar 16 22:37 minimal-c4b6e89240744702871f.js  
-rw-r--r--  1 kr0m  kr0m     1976 Mar 16 22:37 minimal-c4b6e89240744702871f.js.LICENSE.txt

Later on, we will set up a HaProxy to balance the traffic, which poses a problem for determining the origin of the request since all IP-level packets come from the balancer. To be able to read the information correctly, we must:

  • Add the X-Forwarded-For header if it is HTTP traffic
  • Use the Proxy Protocol if it is HTTPS traffic

NOTE: We could also use Proxy Protocol for HTTP, but ProxyProtocol is a binary protocol, so it would be more difficult to debug than plain HTTP.

We leave the main Nginx configuration file practically as it comes by default, but we assign the value of the X-Forwarded-For header to the $proxy_protocol_addr variable and define a new log format called proxyprotocollogformat that will use this variable.

    proxy_set_header X-Forwarded-For $proxy_protocol_addr;  
    log_format proxyprotocollogformat  
            '$proxy_protocol_addr - $remote_user [$time_local] '  
            '"$request" $status $body_bytes_sent '  
            '"$http_referer" "$http_user_agent"';

Then we include the configuration file for the vhost itself, in this case rxwod.conf

include rxwod.conf;

And finally, we indicate that we should only trust 192.168.69.11 (HaProxy IP) when we obtain the data from the origin IP of the request, and we will obtain this information by reading the X-Forwarded-For header.

   set_real_ip_from    192.168.69.11;  
   real_ip_header      X-Forwarded-For;

The file would be as follows:

vi /usr/local/etc/nginx/nginx.conf

worker_processes  1;
events {
    worker_connections  1024;
}
http {
    include       mime.types;
    default_type  application/octet-stream;
    proxy_set_header X-Forwarded-For $proxy_protocol_addr;
    log_format proxyprotocollogformat
            '$proxy_protocol_addr - $remote_user [$time_local] '
            '"$request" $status $body_bytes_sent '
            '"$http_referer" "$http_user_agent"';
    include rxwod.conf;
    sendfile        on;
    keepalive_timeout  65;
    server {
        listen       80;
        server_name  localhost;
	
        set_real_ip_from    192.168.69.11;
        real_ip_header      X-Forwarded-For;
        location / {
            root   /usr/local/www/nginx;
            index  index.html index.htm;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/local/www/nginx-dist;
        }
    }
}

As we can see, the HTTP configuration is very basic since we will only use it to renew the SSL certificate and our application will only be accessible via HTTPS due to dealing with personal data such as login.

The SSL configuration is a bit more complex. First, we indicate that we should only trust 192.168.69.11 (HaProxy IP) when we obtain the data from the request’s source IP, but this time we will obtain this information through ProxyProtocol.

    set_real_ip_from 192.168.69.11;
    real_ip_header proxy_protocol;

When content is requested under the /static/ path, we will serve the files from the /usr/local/www/rxWod/ directory.

    location /static/ {
        root /usr/local/www/rxWod/;
    }

For the rest of the requests, we will make a proxy_pass to Daphne but first we will assign certain headers:

        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-Host $server_name;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://rxwod.alfaexploit.com:8000;

The final configuration file would be as follows:

vi /usr/local/etc/nginx/rxwod.conf

server {
    listen 443 ssl default_server proxy_protocol;
    server_name rxwod.alfaexploit.com;
    set_real_ip_from 192.168.69.11;
    real_ip_header proxy_protocol;
    ssl_certificate "/usr/local/etc/ssl/rxwod.alfaexploit.com/fullchain.cer";
    ssl_certificate_key "/usr/local/etc/ssl/rxwod.alfaexploit.com/rxwod.alfaexploit.com.key";
    location = /favicon.ico { access_log off; log_not_found off; }
    location /static/ {
        root /usr/local/www/rxWod/;
    }
    location /{
        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-Host $server_name;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://rxwod.alfaexploit.com:8000;
    }
}

For the proxy_pass to work by domain name as we see in the configuration, we will have to make rxwod.alfaexploit.com resolve to the server’s own IP:

vi /etc/hosts

192.168.69.12  rxwod.alfaexploit.com

Before restarting Nginx, we must obtain the SSL certificate:

​pkg install curl socat
curl https://get.acme.sh | sh -s email=kr0m@alfaexploit.com

We configure ACME for the rxwod domain:

/root/.acme.sh/acme.sh --issue -d rxwod.alfaexploit.com -w /usr/local/www/nginx --renew-hook ‘rm -rf /usr/local/etc/ssl/rxwod.alfaexploit.com/ && cp -r /root/.acme.sh/rxwod.alfaexploit.com /usr/local/etc/ssl/ && service nginx restart’
cp -r /root/.acme.sh/rxwod.alfaexploit.com /usr/local/etc/ssl/

We restart the service:

service nginx restart


Haproxy

The configuration of HaProxy is very similar to the one described in this other article , so I won’t repeat it for the sake of brevity of the article. Basically, it consists of filtering by domain name and sending traffic to the relevant backend. If accessed via HTTP, it will redirect to HTTPS unless it is LetsEncrypt trying to renew the certificate.

When we send traffic from HaProxy to web servers, they will only see the HaProxy’s IP address as the source IP, not the real client’s IP. To avoid this, we will make HaProxy add a header called X-Forwarded-For for HTTP and use ProxyProtocol for HTTPS.


Final Touches

We already have our project working, but there are still some measures we must take before exposing it to the general public. All parameters will be configured in the rxWodProject/settings.py file, but to be able to git the setting.py, we will control the values to be loaded through an external file (production_enabled) that will be ignored.

vi rxWodProject/settings.py
"""
Django settings for rxWodProject project.

Generated by 'django-admin startproject' using Django 3.1.7.

For more information on this file, see
https://docs.djangoproject.com/en/3.1/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""

from pathlib import Path
import os

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
secret_key_path = str(Path(__file__).resolve().parent) + '/secret_key.txt'
with open(secret_key_path) as f:
    SECRET_KEY = f.read().strip()

production_enabled = str(Path(__file__).resolve().parent) + '/production_enabled'
f = open(production_enabled, "r")
lines = f.readlines()
f.close()
if lines[0].strip().split(' ')[0] == '1':
    PRODUCTION_ENABLED = True
else:
    PRODUCTION_ENABLED = False

if PRODUCTION_ENABLED:
    ALLOWED_HOSTS = ['rxwod.alfaexploit.com']
    DEBUG = False
    SECURE_SSL_REDIRECT = True
    SECURE_HSTS_SECONDS = 3600
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True
    CSRF_COOKIE_SECURE = True
    SESSION_COOKIE_SECURE = True
    CONN_MAX_AGE = 1
    ADMINS = [('kr0m', 'kr0m@alfaexploit.com')]
else:
    # SECURITY WARNING: don't run with debug turned on in production!
    DEBUG = True
    ALLOWED_HOSTS = []


INTERNAL_IPS = [
    '127.0.0.1',
]

# Application definition

INSTALLED_APPS = [
    'rxWod.apps.RxwodConfig',
    'usersAuth.apps.UsersauthConfig',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'debug_toolbar',
    'webpack_loader',
]

if PRODUCTION_ENABLED:
    MIDDLEWARE = [
        'django.middleware.common.BrokenLinkEmailsMiddleware',
        'django.middleware.security.SecurityMiddleware',
        'django.contrib.sessions.middleware.SessionMiddleware',
        'django.middleware.common.CommonMiddleware',
        'django.middleware.csrf.CsrfViewMiddleware',
        'django.contrib.auth.middleware.AuthenticationMiddleware',
        'debug_toolbar.middleware.DebugToolbarMiddleware',
        'django.contrib.messages.middleware.MessageMiddleware',
        'django.middleware.clickjacking.XFrameOptionsMiddleware',
    ]
    MANAGERS = [('kr0m', 'kr0m@alfaexploit.com')]

    import re
    IGNORABLE_404_URLS = [
        re.compile(r'\.(php|cgi)$'),
        re.compile(r'^/phpmyadmin/'),
    ]
else:
    MIDDLEWARE = [
        'django.middleware.security.SecurityMiddleware',
        'django.contrib.sessions.middleware.SessionMiddleware',
        'django.middleware.common.CommonMiddleware',
        'django.middleware.csrf.CsrfViewMiddleware',
        'django.contrib.auth.middleware.AuthenticationMiddleware',
        'debug_toolbar.middleware.DebugToolbarMiddleware',
        'django.contrib.messages.middleware.MessageMiddleware',
        'django.middleware.clickjacking.XFrameOptionsMiddleware',
    ]

ROOT_URLCONF = 'rxWodProject.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'rxWodProject.wsgi.application'


# Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases

#DATABASES = {
#    'default': {
#        'ENGINE': 'django.db.backends.sqlite3',
#        'NAME': BASE_DIR / 'db.sqlite3',
#    }
#}

database_auth_path = str(Path(__file__).resolve().parent) + '/database_auth.txt'
f = open(database_auth_path, "r")
lines = f.readlines()
f.close()
DB_USERNAME = lines[0].strip().split(' ')[0]
DB_PASSWORD = lines[1].strip().split(' ')[0]

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': 'rxwod',
        'USER': DB_USERNAME,
        'PASSWORD': DB_PASSWORD,
    }
}


# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/

LANGUAGE_CODE = 'es-es'

TIME_ZONE = 'Europe/Madrid'

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/

STATIC_URL = '/static/'

STATICFILES_DIRS = (
    os.path.join(BASE_DIR, 'assets/bundles/'),
)

STATIC_ROOT = '/usr/local/www/rxWod/static/'

WEBPACK_LOADER = {
    'DEFAULT': {
        'BUNDLE_DIR_NAME': '',
        'LOADER_CLASS': 'fixes.webpack.CustomWebpackLoader',
        'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json'),
    }
}

# Email configuration
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'

email_auth_path = str(Path(__file__).resolve().parent) + '/email_auth.txt'
f = open(email_auth_path, "r")
lines = f.readlines()
f.close()

EMAIL_HOST = lines[0].strip().split(' ')[0]
EMAIL_USE_TLS = lines[1].strip().split(' ')[0]
EMAIL_PORT = lines[2].strip().split(' ')[0]
EMAIL_HOST_USER = lines[3].strip().split(' ')[0]
EMAIL_HOST_PASSWORD = lines[4].strip().split(' ')[0]

LOGIN_URL='/accounts/login'
LOGIN_REDIRECT_URL='/'

We ignore the control file:

vi .gitignore

rxWodProject/secret_key.txt
rxWodProject/database_auth.txt
rxWodProject/email_auth.txt
rxWod/migrations
usersAuth/migrations
db.sqlite3
node_modules
yarn-error.log
assets/bundles/*
assets/videos/*
webpack-stats.json
django.mo
djangojs.mo
__pycache__/
*.core
rxWodProject/production_enabled

Depending on the environment we are in, we will assign one value or another to the file:

cd /home/kr0m/rxWod/rxWodProject/rxWodProject

Production:

echo 1 > production_enabled

Development:

echo 0 > production_enabled

Next, I explain each of the parameters in detail.

We disable Debug mode, which is very useful when developing but can leak information if kept in production, as well as obtaining lower performance, so we disable it:

DEBUG = False

The MEDIA_URL/MEDIA_ROOT parameters refer to files uploaded by the user. In my case, it will not be necessary to configure them since it is not allowed to upload files in any case, but I leave them documented as a reference:

MEDIA_URL: URL from which media files will be served.
MEDIA_ROOT: Path where the media content is located.

NOTE: These files are not trustworthy since they have been uploaded by the user. Therefore, we must take measures at the web server level to not interpret or execute them.

Regarding SSL access, we will only allow web access through HTTPS since the data is personal and there is a login on it. We do not want anyone to obtain the credentials of one of our users or alter the content of their routines through MITM attacks, for example. By configuring HaProxy, we only allow access through HTTPS unless it is LetsEncrypt trying to renew the certificate.

Another aspect to consider is that our Nginx will send requests to Daphne in plain text. For Daphne to be aware of whether they arrived through HTTP or HTTPS, Nginx must insert a header (X-Forwarded-Proto). This way, we can force SSL ( SECURE_SSL_REDIRECT ) in the Django project.

If we do not insert the header and force SSL (SECURE_SSL_REDIRECT), the Django project will identify the requests as HTTP, and we will enter a loop of redirects since it will always detect the access as insecure:

ERR_TOO_MANY_REDIRECTS

We force access through SSL:

SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 3600
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

Now that we have SSL working, we must configure the CSRF_COOKIE_SECURE parameter to True to mark the CSRF cookies as secure. This way, the browser will only allow the use of cookies through HTTPS.

CSRF_COOKIE_SECURE = True

This parameter is identical to the previous one but for session cookies.

SESSION_COOKIE_SECURE = True

Every time a data is queried in the database, a connection is opened and closed. This is very inefficient, especially in applications that make intensive use of it. To avoid this behavior, we can enable persistent connections. This way, the connections will remain open for X seconds, increasing performance.

The allowed values are:

  • 0: By default, the connection is opened and closed on each request.
  • X: Value expressed in seconds indicating how long the connection should remain open.
  • None: Never close the connection.
CONN_MAX_AGE = 1

When we configure a project to go into production, we must disable the DEBUG mode to make it run much faster and prevent malicious users from obtaining details of the application.

On the other hand, by disabling DEBUG, we will no longer see errors when they occur, which is a problem because the application could be failing without us being aware of it. For these cases, we will use the Django error reporting system .

The error reporting system will behave in one way or another depending on the type of error:

  • 500 internal server error: It will send an email to all users defined in the ADMINS parameter. They will receive a description of the error, a complete Python error trace, and details of the HTTP request. If you have followed the complete guide, all the necessary parameters to be able to send emails should be configured.
ADMINS = [('kr0m', 'kr0m@alfaexploit.com')]
  • 404 page not found error: To enable the sending of these reports, in addition to disabling DEBUG, we must add django.middleware.common.BrokenLinkEmailsMiddleware in the MIDDLEWARE section of the settings.py file. The errors will only be reported if they were produced by a request with a referer and this referer is not the URL of our website. The users to be notified are indicated with the MANAGERS parameter.
MIDDLEWARE = [
    'django.middleware.common.BrokenLinkEmailsMiddleware',
    ...
]
MANAGERS = [('kr0m', 'kr0m@alfaexploit.com')]

We can configure regular expressions so that we are not informed about certain requests, for example, not to inform us if they try to access .php, .cgi files, or any URL that hangs from /phpmyadmin/.

import re
IGNORABLE_404_URLS = [
    re.compile(r'\.(php|cgi)$'),
    re.compile(r'^/phpmyadmin/'),
]

We must bear in mind that the reports contain sensitive information, and we should only report them to trusted personnel through secure channels. Even when the personnel is trustworthy, there are certain data that we may not want to be sent, such as credit card numbers, for example. Django allows us to obfuscate such data:

If we need to generate custom reports, we can also do it:
https://docs.djangoproject.com/en/3.1/howto/error-reporting/#custom-error-reports

We can check that most of the parameters are correctly configured using the command:

cd /home/kr0m/rxWod/rxWodProject
python manage.py check --deploy

System check identified no issues (0 silenced).

Some additional considerations before going into production are:

  • Use caching for sessions
  • Cache templates

Finally, we configure our DNS so that the domain points to the IP address of our HaProxy and access our website to check its correct operation:


Second programmer

If we expand our team of programmers, the new addition will only have to follow the following steps:

We install the Git client:

pkg install git

We install Yarn:

pkg install npm
npm install -g yarn

We perform the initial Git configuration (as a regular user):

git config --global user.name “coder2”
git config --global user.email coder2@alfaexploit.com

We create the Django project and clone the repository:

python3.7 -m venv rxWod
cd rxWod
git clone gitolite@192.168.69.5 :RxWodProject

We install its dependencies, some of which are problematic using pip, so we install them globally:

pkg install py37-tkinter py37-sqlite3

The rest using pip:

source bin/activate
/usr/home/kr0m/rxWod/bin/python3.7 -m pip install --upgrade pip
pip install wheel
pip install -r RxWodProject/requirements.txt
mv RxWodProject rxWodProject
cd rxWodProject

We install the JS dependencies:

yarn install

We fill in the credential and sensitive information files:

vi rxWodProject/secret_key.txt
vi rxWodProject/database_auth.txt
vi rxWodProject/email_auth.txt

We create the directory for the bundles:

mkdir assets/bundles/

We execute the possible pending migrations in the database:

python manage.py makemigrations
python manage.py migrate

We compile the WebPack bundles and start the test server:

yarn dev-build


Deploy script:

Every time we upload any changes to the repository, we must deploy them on the production server. Below is a script to deploy changes more efficiently.

One of the steps requires restarting the Daphne server, so we will configure sudo.

pkg install sudo
vi /usr/local/etc/sudoers.d/kr0m

kr0m ALL=(ALL) NOPASSWD: /usr/sbin/service daphne restart

As a regular user:

mkdir .scripts
vi .scripts/deploy.sh

#!/usr/local/bin/bash
cd /home/kr0m/rxWod/rxWodProject
git pull
cd ..
source bin/activate
pip install -r rxWodProject/requirements.txt
cd rxWodProject
python manage.py makemigrations
python manage.py migrate
yarn install
yarn prod-build
python manage.py collectstatic --noinput
sudo /usr/sbin/service daphne restart
chmod 700 .scripts/deploy.sh

This article has turned out to be much longer than expected, but I think we have explained the whole process in the simplest possible way without leaving any loose ends. However, if you have any questions, you can always contact me via Telegram and ask any necessary questions.

If you liked the article, you can treat me to a RedBull here