New Wibewa Wagtail Blog

Notes on Antonis Christofides's Book, Deploying Django on a Single Debian or Ubuntu Server

Jan. 25, 2025

Acknowledgement and Starting Point

This post is about steps I took to deploy a Django project to a VPS running Debian 12. I used Putty on a Windows 11 laptop to connect to the VPS. My Django project is on the laptop for development.

This post is mostly my notes on Deploying Django on a single Debian or Ubuntu Server by Antonis Christofides. That book has been a guide to me and I keep refering to it. But here I've added notes about steps that you may do differently, in some cases must do differently because the book is a few years old. I recommend following Mr. Christofides's book and looking to this post for additional information that might be helpful. That's especially true if you're setting up your VPS from scratch, because Chapters 1 and 2 have a lot of information about finding a VPS provider that I won't be touching here

The book uses placeholders $DJANGO_PROJECT for the project name and $DJANGO_USER for the user name, noting that they're usually the same but can be different. In this post, I have more placeholders and instead of the $SOMETHING format, my placeholders will look more like benvillesomething.

Some of my placeholders will be:

Plus a couple more. The first three names can all be the same in your setup

Users

Mr. Christofides strongly recommends using ssh keys, but there are some cases where you must or prefer to stick with user names and passwords. I'm not recommending one over the other. If you stick with passwords, you should create a user with sudo privileges and disable ssh for root. For the project I'm working on as I write this post, I'm logging in as a sudo-enabled user. Most of my examples will reflect that. I also added two-factor authentication.

There are several tutorials online for creating a sudo-enabled user and disabling ssh for root on Debian systems, for example How-To Geek's. Here's how I do it:

Two Factor Authentication

For the project I'm working on, I'm using passwords and 2FA

2FA works with an app that has to be set up on a device such as your phone. I use Bitwarden which also has a Windows desktop app.

To enable 2fa, I installed libpam-google-authenticator

sudo apt install -y libpam-google-authenticator

For those suspicious of Google, Linux Babe tells us that the authentication process doesn't send anything to Google - they just wrote the open-source software

The next step will display a QR code in your terminal.

google-authenticator

I was suprized the first time I did this to see the QR code pop up in a console. Your authenticator app should have an option somewhere that allows you to scan a code to set up TOTP (Time-based One Time Password). If the code is too big for you to scan and you're using Putty, you can make your font smaller by following these steps:

If you can't scan the code, your app should also have an option to enter the secret key manually

I suggest continuing with Linux Babe's instructions, although there are others you can find.

I also suggest staying logged in with one ssh window to make changes and log in with a separate window to test.

I also recommend learning on a VPS that you can painlessly rebuild in case you lock yourself out. If you can't or don't want to rebuild, your VPS provider probably has a way for you to log in with an admin console from their website

Project System User

Logged in as benvilleadmin, I created benvilleus with the following command:

sudo adduser --system --home=/var/opt/benvillelibrarysite --disabled-password --group --shell=/bin/bash benvilleus

If I were logged in as root with ssh keys, the command would be the same except without sudo.

As the book says, it doesn't matter much what the home directory is so you're free to give it a different name than benvillelibrarysite, or even place it somewhere other than /var/opt/. You can also skip the --home option and leave the home directory as non-existant. In most cases sticking with --home=/var/opt/benvillelibrarysite is the best choice

Mr. Christofides's example includes "no-create-home", because he covered creating the directory separately, but I didn't bother with that so I let the system create the directory for me

Directories

Chapter 3 of Deploying Django on a single Debian or Ubuntu Server provides good information about the directory structure of a Linux machine and how that structure relates to the software people develop and install.

On my laptop, my project structure is something like what's below. I made some changes for clarity. I also included additional apps (touglates, wibekwa, and wibekwa_base) that I added to the project I'm working on. I won't discuss those apps in this post but I included them in the structure so you can see where the apps that you might be working on would be

My development structure is:

benvillelibrarysite/
    benvillelibrary/  
        settings/
            base.py
            dev.py
        static/
        templates/
        urls.py
        wsgi.py
    media/
    touglates/
    venv/
    wibekwa/
    wibekwa_base/
    db.sqlite3
    manage.py
    requirements.txt

On the production server, the files and directories look something like this

/etc/opt/benvillelibrarysite/             /etc/opt is for settings specific to the app on this server
    static/
    settings/
        local.py
/opt/benvillelibrarysite/                 /opt is for applications not part of the operating system
    benvillelibrary/  
        settings/
            base.py
            production.py
        static/
        templates/
        urls.py
        wsgi.py
    touglates/
    venv/
    wibekwa/
    wibekwa_base/
    manage.py
    requirements.txt
/var/opt/benvillelibrarysite/            /var is for data that changes as the app is used
    benvillelibrarysite.db
    media/

Project Root Directory

The project root directory holds the program files. I created the project root directory /opt/benvillelibrarysite

sudo mkdir /opt/benvillelibrarysite

Then, as the book says, I "clone or otherwise copy" my Django project into the new directory.
For me, "clone or otherwise copy" meant a combination of using sftp to upload from my laptop, cloning apps from my repositories, and creating or recreating some of files.

I used Filezilla (but other sftp clients, like Win-SCP are fine) to upload requirements.txt, manage.py, and the django package directory

In my case, with root disabled, I can't sftp directly to /opt/benvillelibrarysite/, so I created the directory /home/benvilleadmin/transfer/, and uploaded to that directory.

Then with Putty I copied the files and directories from the transfer directory to the project root directory.

sudo cp /home/benvilleadmin/transfer/* /opt/benvillelibrarysite/

I added some of the apps that I'm using for this project by cloning them from Github

From inside the project root directory:

sudo git clone https://github.com/tougshire/wibekwa
sudo git clone https://github.com/tougshire/wibekwa_base
sudo git clone https://github.com/tougshire/touglates

Virtualenv

The next step is setting up the virtual environment. The following code block from the book combines two commands:

virtualenv --system-site-packages --python=/usr/bin/python3 \
    /opt/$DJANGO_PROJECT/venv
/opt/$DJANGO_PROJECT/venv/bin/pip install \
    -r /opt/$DJANGO_PROJECT/requirements.txt

The first command is

virtualenv --system-site-packages --python=/usr/bin/python3 \
    /opt/$DJANGO_PROJECT/venv

If your system doesn't have virtualenv installed, install it with sudo apt install virtualenv

For my set up, the first command translates to

sudo virtualenv --system-site-packages --python=/usr/bin/python3 /opt/benvillelibrarysite/venv

The above created the virtual environment /opt/benvillelibrarysite/venv

On my laptop, I had previously created requirements.txt using pip freeze. To do this, I called the following command with the virtual environment activated from the development project root directory:

pip freeze > requirements.txt

During develpment, you almost certainly issued commands from within an activated virtual environment, but you don't have to activate the environment before running these commands on the server

With requirements.txt uploaded to the server, the next command installs the same packages on the server that you had in development

In the book, the command is

/opt/$DJANGO_PROJECT/venv/bin/pip install \
    -r /opt/$DJANGO_PROJECT/requirements.txt

For my project, that translates to

sudo /opt/benvillelibrarysite/venv/bin/pip install \-r /opt/benvillelibrarysite/requirements.txt

' Then I compiled the project

sudo /opt/benvillelibrarysite/venv/bin/python -m compileall -x /opt/benvillelibrarysite/venv/ /opt/benvillelibrarysite

The Data Directory and the Log Directory

Following the book, you would create the data directory at this point but I already created /var/opt/benvillelibrarysite when I created benvilleus.

I created the log directory as per section 3.4

sudo mkdir -p /var/log/benvillelibrarysite
sudo chown benvilleus /var/log/benvillelibrarysite

The Production Settings Directory

As noted, on Debian based systems, configuration settings for programs that are installed in /opt/ go in /etc/opt/

sudo mkdir /etc/opt/benvillelibrarysite

I created /etc/opt/benvillelibrarysite/settings.py and edited it as follows:

from benvillelibrary.settings import *

DEBUG = True
ALLOWED_HOSTS = ['benvillelibrary.gov', 'www.benvillelibrary.gov']
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': '/var/opt/benvillelibrarysite/benvillelibrarysite.db',
    }
}

Then I set the permissions as below.

sudo chgrp benvilleus /etc/opt/benvillelibrarysite
sudo chmod u=rwx,g=rx,o= /etc/opt/benvillelibrarysite

Then I compiled the files

sudo /opt/benvillelibrarysite/venv/bin/python -m compileall /etc/opt/benvillelibrarysite

With all of this set up, I tested the server, as per Section 3.7 near the end

sudo -u benvilleus PYTHONPATH=/etc/opt/benvillelibrarysite:/opt/benvillelibrarysite \
DJANGO_SETTINGS_MODULE=settings \
    /opt/benvillelibrarysite/venv/bin/python \
    /opt/benvillelibrarysite/manage.py \
    runserver 0.0.0.0:8000

The book notes that you can use --settings instead of DJANGO_SETTINGS_MODULE but I prefer DJANGO_SETTINGS_MODULE because it's clearer to me.

I'm using sudo -u benvilleus to become benvilleus for this command. Sudo without -u defaults to root.

Since I am using sudo, I'm not using su. So instead of

su - c "/opt/benvillelibrarysite ..."

I used

/opt/benvillelibrarysite ...

I changed directory to /opt/benvillelibrarysite before running the commands because I can't run the them from benvilleadmin's home dictory

The web server

I followed Chapter 4, using Apache because I'm more familiar with Apache than with Nginx.

But one big thing I did, which isn't in the book, is I added ssl with Certbot.

Ssl changes your protocol from http to https (https://benvillelibrary.gov). Without ssl, your visiters may get security warnings.

You can follow the first sections and stop at 4.3 for Nginx or 4.6 for Apache, install the certificate as per my instructions below, and then continue where you stopped

To enable ssl, you need a certificate, and one way to obtain a certificate is with Certbot.

For those who have used Certbot before, you may have installed it with

sudo apt install certbot  #<-- no longer recommended

but Certbot now "strongly recommends" installing with Snap. Since Snap wasn't preinstalled on Debian 12 minimal, I installed it following the instructions at snapcraft.io/docs/installing-snap-on-debian, which are

sudo apt update
sudo apt install snapd

with Snap installed, I installed Certbot in accordance with their instructions at certbot.eff.org/instructions?ws=apache&os=snap with

sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

And ran it with

sudo certbot --apache

(certbot also has a --nginx option)

From there I followed Certbot's instructions, choosing to add a redirect so when visitors request http://benvillelibrary.gov they automatically get https://benvillelibrary.gov

As per the book, I already created a config file named /etc/apache2/sites-available/benvillelibrary.gov.conf. Certbot created a new file named /etc/apache2/sites-available/benvillelibrary.gov-le-ssl.conf, and added a rewrite in the first file to redirect to the second

At this point, the only directives in the first conf file that are important are the server name, server aliases, and the rewrite directives. So any edits the book says to make in bevillelibrary.gov.conf should be made in benvillelibrary.gov-le-ssl.conf instead.

Static and Media Files

I followed Chapter 5 pretty much as described. The collectstatic command looks like this for me

sudo PYTHONPATH=/etc/opt/benvillelibrarysite:/opt/benvillelibrarysite \
    DJANGO_SETTINGS_MODULE=settings \
    /opt/benvillelibrarysite/venv/bin/python \
    /opt/benvillelibrarysite/manage.py collectstatic

Where the book says to go to http://$DOMAIN/static/admin/img/icon_searchbox.png use https://$DOMAIN/static/admin/img/search.svg (i.e. https://benvillelibrary.gov/static/admin/img/search.svg) instead because
the latest Django uses a different set of images. Also http is changed to https on the assumption that you've enabled ssl

Gunicorn

Chapter 6 says to install Gunicorn with Pip instead of Apt because the Debian packaged Gunicorn only supports Python 2.
The part about Python 2 is no longer true, but it's still best to install Gunicorn into the virtual environment with Pip.

sudo /opt/benvillelibrarysite/venv/bin/pip install gunicorn

If you already installed Gunicorn on your system (outside the virtual environment), you'll probably get a message saying Gunicorn is already installed - and it won't let you go forward. You can override that check with -I

sudo /opt/benvillelibrarysite/venv/bin/pip install -I gunicorn

See the Gunicorn docs page Deploying Gunicorn

My version of the command to run Gunicorn from the command line is

sudo -u benvilleus PYTHONPATH=/etc/opt/benvillelibrarysite:/opt/benvillelibrarysite \
    DJANGO_SETTINGS_MODULE=settings \
    /opt/benvillelibrarysite/venv/bin/gunicorn \
    benvillelibrary.wsgi:application

Systemd

Section 6.3 of the book is configuring systemd. This is how to get the project running when the server starts without you having to start it from the command line. The remaining sections of Chapter 6 cover optimizing. I don't have much to add to Chapter 6 except for the 2 core VPS that I'm using as I write these instructions, I increased my workers to 8. I can probably go higher, but I don't expect the project I'm working on to have a lot of visiters

Mail

Mr. Christofides recommended a mail transport agent called Dragonfly Mail Agent, or dma. I couldn't get dma to work. I was able to draft the test message but dma never sent the email, didn't create a log, and didn't report any errors. I'll try on a differnet vps sometime and maybe update this post, but for project I'm working on as I write this post, I used Postfix

sudo apt install postfix

Where the Postfix installation wizard asked for type of configuration, I chose "Internet Site". Where it asked for the system mail name, I entered "mailer.benvillelibrary.gov".

Then I edited /etc/postfix/main.cf as follows:

After making the above edits, I mapped the password file to a database with:

sudo postmap /etc/postfix/sasl_passwd

Then I restarted Postfix with

sudo systemctl stop postfix
sudo systemctl start postfix

I added the following lines to /etc/opt/benvillelibrarysite/settings.py:

SERVER_EMAIL = 'info@benvillelibrary.gov'  
DEFAULT_FROM_EMAIL = 'info@benvillelibrary.gov'  
ADMINS = [
    ('administrator', 'admin@benvillelibrary.gov'),
]
MANAGERS = ADMINS

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'localhost'
EMAIL_HOST_USER = ''
EMAIL_HOST_PASSWORD = ''
EMAIL_PORT = 25
EMAIL_USE_TLS = False

Then restarted the project with

sudo service benvillelibrarysite stop
sudo service benvillelibrarysite start

I checked Django's ability to send an email through Postfix by running

sudo -u benvilleus PYTHONPATH=/etc/opt/benvillelibrarysite:/opt/benvillelibrarysite \
DJANGO_SETTINGS_MODULE=settings \
/opt/benvillelibrarysite/venv/bin/python \
/opt/benvillelibrarysite/manage.py shell

and with the shell active:

from django.conf import settings
from django.core.mail import send_mail

admin_emails = [x[1] for x in settings.ADMINS]
send_mail("Test", "This is a  test", settings.SERVER_EMAIL,
          admin_emails)

More to come

I'll add more to this post but Mr. Christofides's book is pretty complete and you can probably just continue with that from here.

Tags