As part of this series on creating and deploying a full stack web app, I’m using the Django web framework. Django has pre-designed parts for you compared to Flask which I’ve used previously. That saves some development time, although it is a bit harder to understand initial as many things are implicit or defined in different ways. One great thing is that it has an admin backend up and running straight away, so you can edit any data on the backend directly without needing to create the views or edit the database manually.

PART 1: Deployment Choices
PART 2: Setting Up Django Cheat Sheet
PART 3: Deployment using Ansible and Pyenv (this post)
PART 4: Alternative deployment with Docker (future post)

This article will demonstrate the steps to reliably and repeatably deploy a Django app in production using Ansible, which is a tool for automating server and infrastructure deployment.

 

Introduction

Django is easy to run locally for development, however for production use you need to deploy to a VPS and serve using production level server software rather than the built in Django development server.

This article describes how to automate this setup with Ansible, using common options of Ubuntu, Ningx (the web server), uWSGI (a bridge between python and the web server), and SQLite for the database. This article assume the local machine on which Ansible is running is MacOS, so Linux and Windows users may need to make some minor modifications.

Ansible is a python based automation tool which allows repeatable automation of server tasks. This means once this is configured as per this article, the same Ansible playbook can be used to deploy other Django projects (with minor modifications as required). Furthermore, any changes to the project can be reliably made by running the Ansible playbook again.

 

Prerequisites

Before getting started, ensure that a Django project is available and working locally, and upload it to a Github repository – example here. Creating a Django project is outside the scope of this article, but you will need do at least the following things for a basic setup:

  • Create a Django project: `django-admin startproject example-project-name`
  • Create a Django app within the project: `python manage.py startapp example-app-name`
  • Add details to your apps settings.py file, especially adding your domain name to ALLOWED_HOSTS, add a .env file with any passwords etc,
  • Setup db.sqlite3 as the database, create your models, views, templates and application logic.
  • Collect static files into a your django project folder: `python manage.py collectstatic`
  • Make migrations to apply your models to the database `python manage.py makemigrations`
  • Optionally you can create a superuser to login to your admin backend: `python manage.py createsuperuser`
  • Start the Django development server to check the project works locally: `python manage.py runserver`

Step 1. Installing Ansible on your local machine

Install Ansible on your local machine (assuming you have python installed):.

mkdir ansible-roles
cd ansible-roles
python3 -m venv env
source env/bin/activate
pip3 install ansible

Ansible performs actions on a remote server over an SSH connection. SSH access is most secure if you use SSH keys rather than a password. Let’s first generate a public and private key pair:

mkdir ssh_keys
ssh-keygen -t rsa -b 2048 -f ./my_ssh_key

In the ssh_keys directory there will now be two files, `ssh_keys` and `ssh_keys.pub`. `ssh_keys` is your private key file and should be kept safe. You can now open the `ssh_keys.pub`, which contains the public key.

This article assumes the public key is named `my_ssh_key.pub` and private key is name `my_ssh_key` and both are available in the ssh_keys directory. For Django apps residing in a private Github repo, the best practice with regards to security is to forward this SSH key to the remote server from your local machine, since storing a Github password or SSH key on the server is a risk. To do this, create another SSH key pair. Paste the public key (another_SSH_key.pub) into Github: Repository > Settings > SSH and GPG keys > New SSH key (Note that each Github repo requires a unique SSH key).

Configure Ansible to forward SSH keys, by creating `ansible.cfg` with the following:

[defaults]
host_key_checking = False
[ssh_connection]
ssh_args = -o ForwardAgent=yes

Ensure the matching private key is accessible on your local machine (for example by adding it to ssh-agent).

 

Step 2. Deploy virtual server

Sign up for an account at a VPS provider such as AWS, Vultr, Digital Ocean etc and deploy a new Ubuntu server (these instructions assume Ubunut 22), ensuring you upload and select your SSH key.

Once the server has finished deploying, copy the IP address. Change your DNS ‘A’ record to point to this IP address.

 

Step 3. Create Ansible Roles, Directory Structure and Variables

Ansible’s automation files are called ‘roles’. Setup the directory structure (inside the ansible-roles directory created in step 1), for this project:

    ansible-roles
    ├── roles
    │   ├── common
    │   │   ├── handlers
    │   │   │   └── main.yml
    │   │   └── tasks
    │   │       ├── main.yml
    │   │       ├── ubuntu.yml
    │   │       ├── ufw.yml
    │   │       └── users.yml
    │   └── djangoapp
    │       ├── tasks
    │       │   ├── buildessentials.yml
    │       │   ├── django.yml
    │       │   ├── main.yml
    │       │   ├── python.yml
    │       │   └── server.yml
    │       └── templates
    │           ├── nginx.j2
    │           ├── uwsgi.j2
    │           └── uwsgi.service.j2
    ├── ssh_keys
    │   ├── ssh_key
    │   └── ssh_key.pub
    ├── ansible.cfg
    └── deploy-django.yml

 

Edit the `deploy-django.yml` file to contain the following, which is the playbook entry point:

- name: apply common configuration to server
hosts: all
become: true
user: root
environment:
PATH: "{{ ansible_env.PATH }}:/root/.pyenv/bin"
roles:
- common
- djangoapp

Edit the `/group_vars/all` file to contain the following. These variables tell Ansible all of your project specific settings. Do not add this file to source control as it contains passwords and project specific settings.

ssh_dir: ./ssh_keys
DOMAIN_NAME: example.com
GIT_REPO: git@github.com:example_github_username/example_repo_name.git
GIT_BRANCH: main
PROJECT_NAME: example-project-name
MODULE_NAME: example-project-name
APP_NAME: example-app-name
LOCAL_APP_DIR: ~/example-repo
NGINX_CONF_DIR: /etc/nginx/sites-enabled/
NGINX_SITES_DIR: /etc/nginx/sites-available/
PYTHON_VERSION: 3.11.5

Edit the `common/handlers/main.yml` file to contain the following, so the SSH server service can be restarted when required:

  - name: restart SSHd
    service: name=SSH state=restarted

 

Step 4. Create Ansible tasks for basic server setup

Ansible automation is easier to manage if we break it down into roles tasks. Let’s create files for each of our tasks in the common role, which could be used for setting up any server:

cd roles/common/tasks
touch hosts main.yml users.yml ufw.yml ubuntu.yml

You could add all Ansible tasks to one file, however for clarity and reusability, it’s better to separate out into different sections, and reference these from the main entry point for the common role, `common/tasks/main.yml`. Add the following to this file:

- include: ubuntu.yml
- include: users.yml
- include: ufw.yml

In `common/handlers/ubuntu.yml` add the following commands. This updates the system with the latest packages, sets the timezone and add installs the security package fail2ban:

  - name: Update and upgrade apt packages
  apt:
      upgrade: yes
      update_cache: yes
      cache_valid_time: 86400 # 1 day

  - name: Set timezone to Europe/London
  timezone:
      name: Europe/London

  - name: Install basic server packages
  apt: name={{item}}
  become: true
  with_items:
      - fail2ban
      - ntp

For security, you can remove password access via SSH and force the use of SSH keys which is more secure. Although we won’t login using a password, to enable root login the root user does require a password. In `users.yml` add the following:

  - name: Change root password
    user: name=root password={{ ROOT_PASSWORD | password_hash('sha512') }} update_password=always
  
  - name: Disable SSH password login
    lineinfile: dest=/etc/SSH/SSHd_config regexp="^#?PasswordAuthentication" line="PasswordAuthentication no"
    notify: restart SSHd

Setup the Uncomplicated Firewall to allow SSH access on port 22 (for command line / Ansible access), web access on port 80 and secure web access on port 443 (for your Django app). To do this, edit the `ufw.yml` file to contain:

  - name: Set default firewall policy to deny all
    ufw: direction=incoming policy=deny
  - name: enable SSH in firewall
    ufw: rule=allow port=22
  - name: enable HTTP connections for web server
    ufw: rule=allow port=80
  - name: enable HTTPS connections for web server
    ufw: rule=allow port=443
  - name: enable firewall
    ufw: state=enabled

 

Step 5. Create Ansible tasks to install required server packages

Create `djangoapp/tasks/buildessentials.yml` and add a the following, which installs nginx nload, and certbot for the webserver, and a list of packages that we need to build python and install pacakges from source.

  - name: Install webserver
    apt:
      name:
        - nginx
        - nload
        - certbot
        - python3-certbot-nginx
      state: present

  - name: Install build-essential
    apt:
      name:
        - build-essential
        - llvm
        - libssl-dev
        - zlib1g-dev
        - libbz2-dev
        - libreadline-dev
        - libsqlite3-dev
        - curl
        - libncursesw5-dev
        - xz-utils
        - tk-dev
        - libxml2-dev
        - libxmlsec1-dev
        - libffi-dev
        - liblzma-dev
        - git
        - make
        - libpcre3-dev
        - libz-dev
      state: present

 

Step 6. Create Ansible tasks to install Python using pyenv

For production deployments being able to control the version of Python and a virtual environment with the required packages is important. This article shows how to do this with Pyenv. Pyenv works by modifying the shell with shims to access the correct version and virtual environment. One downside of this is that Ansible commands such as pip will not work for this case and they must be run using ‘command’ or ‘shell’ instead. An alternative approach would be to use Docker to manage the python environment and webserver, but that is outside the scope of this article.

Create an Ansible task in `djangoapp/tasks/python.yml` to install pyenv, and install the version of Python you require.

  - name: Install pyenv using curl
    shell: "curl -s https://pyenv.run | bash"
    args:
      creates: "/root/.pyenv/README.md"

  - name: Update .bashrc file for pyenv
    lineinfile:
      path: "/root/.bashrc"
      line: "{{ item }}"
      create: yes
    loop:
      - 'export PYENV_ROOT="/root/.pyenv"'
      - 'export PATH="$PYENV_ROOT/bin:$PATH"'
      - 'eval "$(pyenv init -)"'
      - 'eval "$(pyenv virtualenv-init -)"'

  - name: Update pyenv
    shell: "pyenv update"

  - name: Install Python {{ PYTHON_VERSION }} with pyenv
    shell: "pyenv install {{ PYTHON_VERSION }}"
    args:
      creates: "/root/.pyenv/versions/{{ PYTHON_VERSION }}"

 

Step 7. Create Ansible tasks to clone the Django app repo and install the python virtual environment

Now our Github repo is accessible and Pyenv is installed, add a task to clone (or pull if it exists) the repo. It’s likely you will have files on your local machine and not in source control (such as a .env file for environment variables containing API keys etc), and these can be copied directly. To do this, in `djangoapp/tasks/django.yml` add:

  - name: Clone a private repository into {{PROJECT_NAME}} folder.
    git:
      repo: "{{ GIT_REPO }}"
      version: "{{ GIT_BRANCH }}"
      dest: "/home/{{ PROJECT_NAME }}"
      accept_hostkey: yes
      update: yes

  - name: Copy .env file to server
    become: true
    copy:
      src: "{{LOCAL_APP_DIR}}/.env"
      dest: "/home/{{ PROJECT_NAME }}/.env"

 

Django apps have various required python packages. It is assumed that these are available in `requirements.txt` in the repo just cloned. To create a python virtualenv with Pyenv and install the requirements, add these new tasks to `djangoapp/tasks/django.yml`:

  - name: Create a Python virtual environment
    ansible.builtin.command: "pyenv virtualenv {{ PYTHON_VERSION }} {{ PROJECT_NAME }}"
    args:
      creates: "/root/.pyenv/versions/{{ PYTHON_VERSION }}/envs/{{ PROJECT_NAME }}"

  - name: Add pip, setuptools, uWSGI in the virtual environment
    ansible.builtin.command: "/root/.pyenv/versions/{{ PYTHON_VERSION }}/envs/{{ PROJECT_NAME }}/bin/python -m pip install {{ item }}"
    with_items:
      - pip
      - setuptools
      - uwsgi

  - name: Read requirements.txt line by line
    ansible.builtin.shell: cat /home/{{ PROJECT_NAME }}/requirements.txt
    register: requirements_contents
    changed_when: false

  - name: Install packages one by one
    ansible.builtin.command: "/root/.pyenv/versions/{{ PYTHON_VERSION }}/envs/{{ PROJECT_NAME }}/bin/python -m pip install {{ item }}"
    loop: "{{ requirements_contents.stdout_lines }}"
    when: requirements_contents.stdout_lines is defined

  - name: create project log directory
    file: "path=/home/{{PROJECT_NAME}}/logs state=directory"

 

The following tasks at the end of `djangoapp/tasks/django.yml` assume that Django uses an sqlite database named ‘db.sqlite3’, and that this has been setup and tested locally. On the first run of the playbook the database will be copied, and on subsequent runs only the migrations will be applied.

  - name: Check if the Django SQLite DB file exists on the remote host
    ansible.builtin.stat:
      path: "/home/{{ PROJECT_NAME }}/db.sqlite3"
    register: db_file

  - name: Copy Django SQLite DB file from localhost if it doesn't exist on the remote host
    ansible.builtin.copy:
      src: "{{LOCAL_APP_DIR}}/db.sqlite3"
      dest: "/home/{{ PROJECT_NAME }}/db.sqlite3"
    when: not db_file.stat.exists

  - name: Apply DB migrations
    ansible.builtin.command: "/root/.pyenv/versions/{{ PYTHON_VERSION }}/envs/{{ PROJECT_NAME }}/bin/python /home/{{ PROJECT_NAME }}/manage.py migrate"

 

Step 8. Create Ansible tasks to configure the server and setup SSL certificate

On your local machine, you can test your Django app using the built-in development server (`python manage.py runserver`). For production, the recommended options are to use Nginx and uWSGI to server the Django app. These both need configuration files. Ansible also comes with the templating engine Jinja, so a generic template can be created and project specific variables pulled in from `group_vars/all`.

Create a template `roles/django/templates/nginx.j2` for the Nginx configuration as below. SSL information is not required since this will be managed automatically by cerbot.

    upstream {{PROJECT_NAME}} {
        server unix:///home/{{PROJECT_NAME}}/{{PROJECT_NAME}}.sock;
    }

    server {
        listen       80;
        server_name  {{ DOMAIN_NAME }};
        charset     utf-8;
        client_max_body_size 75M;
        access_log /var/log/nginx/access.log;
        error_log /var/log/nginx/error.log;

        location / {
            uwsgi_pass  {{PROJECT_NAME}};
            include     uwsgi_params;
        }

        location /static {
            alias /home/{{PROJECT_NAME}}/{{ APP_NAME }}/static;
            expires 365d;
        }
    }

Create a template `roles/django/templates/uwsgi.j2` for the uWSGI configuration and add:

    [uwsgi]
    home = /root/.pyenv/versions/{{ PYTHON_VERSION }}/envs/{{PROJECT_NAME}}/
    logto = /var/log/uwsgi.log
    module = {{MODULE_NAME}}.wsgi
    master = true
    processes = 4
    socket = /home/{{PROJECT_NAME}}/{{PROJECT_NAME}}.sock
    chmod-socket = 666
    max-requests = 5000
    vacuum = true

Create a template `roles/django/templates/uwsgi.service.j2` for the uWSGI service, and add the following. Unlike Nginx, a service needs to be created specifically for uWSGI:

  [Unit]
  Description=uWSGI Django server instance
  After=syslog.target

  [Service]
  ExecStart=/root/.pyenv/versions/{{ PYTHON_VERSION }}/envs/{{ PROJECT_NAME }}/bin/uwsgi --ini /home/{{PROJECT_NAME}}/{{PROJECT_NAME}}_uwsgi.ini
  WorkingDirectory=/home/{{PROJECT_NAME}}
  Restart=always
  KillSignal=SIGQUIT
  Type=notify
  StandardError=syslog
  NotifyAccess=all

  [Install]
  WantedBy=multi-user.target

Create `roles/djangoapp/server.yml` and add the following Ansible tasks to create config files based on the templates above and the variables supplied in `group_vars/all`:

  - name: create project uWSGI config file based on template
    template:
      src: uwsgi.j2
      dest: "/home/{{PROJECT_NAME}}/{{PROJECT_NAME}}_uwsgi.ini"

  - name: create uWSGI systemd service from template
    template:
      src: uwsgi.service.j2
      dest: "/etc/systemd/system/{{PROJECT_NAME}}_uwsgi.service"

  - name: create Nginx config for project based on template
    template:
      src: nginx.j2
      dest: "{{NGINX_CONF_DIR}}/{{PROJECT_NAME}}.conf"

 

In order to complete the server setup, link the Nginx configuration from sites-enabled:

  - name: copy nginx conf from sites-available to sites-enabled
    file:
      src: "{{NGINX_CONF_DIR}}/{{PROJECT_NAME}}.conf"
      dest: "{{NGINX_SITES_DIR}}/{{PROJECT_NAME}}.conf"
      state: link

 

For production web apps, SSL certificates are essential. In step 5 the Let’s Encrypt’s certbot was already installed. Before starting the Nginx server, obtain a certificate and set certbot to renew the certificate every 30 days.

  - name: Setup SSL certificate with Let's Encrypt # adds SSL section to nginx config automatically
    command: "certbot --non-interactive --agree-tos -m {{EMAIL_ADDRESS}} --nginx -d {{DOMAIN_NAME}} -d www.{{DOMAIN_NAME}}"

  - name: Let's encrypt certbot status check
    command: "systemctl status certbot.timer"
    register: systemctl_output

  - name: Let's encrypt certbot status is -
    debug:
      var: systemctl_output.stdout_lines

 

To complete the setup, add Ansible tasks to reload the systemd configuration – since Nginx and uWSGI services have been updated and enable and start both of these services:

  - name: Reload systemd configuration
    command: systemctl daemon-reload

  - name: Start and enable uWSGI and Nginx systemd services
    service:
      name: "{{ item }}"
      state: restarted
      enabled: yes
    with_items:
      - nginx
      - "{{PROJECT_NAME}}_uwsgi"

 

Step 9. Run the playbook

To run the Ansible playbook with all the tasks that have been specified, run the following command, using the IP address of your server: `ansible-playbook deploy-djangoapp.yml -i “192.0.2.1,”`

The status of each task will be displayed. After successful completion, open a web browser at the domain to see the Django app working in production.

 

Debugging Tips

The most likely places to cause issues are:

  • SSH keys not being available or forwarded correctly – read my post for more help – Debugging four common SSH key issues
  • Django settings.py at /home/{{PROJECT_NAME}}/{{PROJECT_NAME}}/settings.py
  • Nginx configuration at /etc/nginx/sites-enabled/{{PROJECT_NAME}}.conf, error log at /var/log/nginx/error.log
  • uWSGI configuration at /home/{{PROJECT_NAME}}/{{PROJECT_NAME}}_uwsgi.iniIf you make any changes to the Django or uWSGI configuration files, ensure you restart the uWSGI service to apply the changes: `systemctl restart {{PROJECT_NAME}}_uwsgi.service`.

For changes to the Nginx configuration, restart the Nginx service: `systemctl restart nginx`. To see a list of running services: `systemctl list-units –type=service –state=running`.