Dockerize Django Web App

I recently created a simple Django Web App for my friend who recently started his own business. I was attempting to find the best way to deploy this without spending any extra money. I have always been partial to docker as far as shipping my applications, as it removes the unknown variable of where users are trying to ship it to.

I looked at lot of places over the internet, pieced pieces together, and eventually I was able to get my application up in running in my Linux VM without any problems.

Preparing for Deployment

Before we can begin containerizing our application, we have to update our settings so that we can pass in Environment Variables for them to be accessed. Open your settings.py file that was created whenever you first started your Django project, in my case this is /core/core/settings.py.

First, we need to add some imports to the top of the file. OS will allow us to get environment variables, get_random_secret_key will generate a secret key for Django if necessary, and logging.config is for adjusting the django logs.

import os
from django.core.management.utils import get_random_secret_key
import logging.config

Find SECRET_KEY in your file and update it to the below. What this does set the this to a random generated key if one is not provided. Make sure you make this an environment variable, or this will be recreated every startup.

SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", get_random_secret_key())

Update the DEBUG and DEVELOPMENT_MODE options so that we can easily switch between Production and Development based on just the environment variables provided. We also need to setup the ALLOWED_HOSTS. This will default to localhost if not provided.

DEBUG = os.getenv("DEBUG", "False") == "True"
DEVELOPMENT_MODE = os.getenv("DEVELOPMENT_MODE", "False") == "True"

ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost").split(",")

Update your database configuration to the below if you are using MySQL. If you are not using MySQL, then this section will be different. This will use the built-in database if we are developing and will use the production database if we are not developing.

if DEVELOPMENT_MODE is True:
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.sqlite3",
            "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
        }
    }
else:
    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.mysql',
            'NAME': os.environ['DJANGO_DATABASE_NAME'],
            'USER': os.environ['DJANGO_DATABASE_USER'],
            'PASSWORD': os.environ['DJANGO_DATABASE_PASSWORD'],
            'HOST': os.environ['DJANGO_DATABASE_HOST'],
            'PORT': os.environ['DJANGO_DATABASE_PORT'],
        }
    }

We have two final steps that will need to be completed to get our application ready for deployment. First define in the settings.py file where your static files will exist on the server. Not defining this will cause any static files (like the admin page CSS and JS) to not load.

STATIC_ROOT = "staticfiles"
STATIC_URL = "staticfiles/"
STATICFILES_DIRS = [
    os.path.join(BASE_DIR, "static")
]

Our final step to prepare for deployment is to setup our logging. The below can be added to the end of your settings.py file.

LOGLEVEL = os.getenv('DJANGO_LOGLEVEL', 'info').upper()

logging.config.dictConfig({
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'console': {
            'format': '%(asctime)s %(levelname)s [%(name)s:%(lineno)s] %(module)s %(process)d %(thread)d %(message)s',
        },
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'formatter': 'console',
        },
    },
    'loggers': {
        '': {
            'level': LOGLEVEL,
            'handlers': ['console',],
        },
    },
})

Creating your Docker File

Now that we have the settings.py file configured properly, we need to setup our docker file. Yours may look different depending on the dependencies that are needed, but this is the baseline for mine to be able to deploy my container. Create a new file called Dockerfile in the top level of your project, and paste the below in it.

FROM python:3.7
ENV PYTHONUNBUFFERED 1
RUN mkdir /app
RUN mkdir -p /var/www/html/staticfiles

WORKDIR /app
ADD core /app
ADD requirements.txt /app/requirements.txt
ADD entrypoint.sh /app/entrypoint.sh
RUN chmod +x entrypoint.sh
RUN apt-get update \
    && apt-get install -y git
RUN git init
RUN apt-get install -y gcc python3-dev \
    && apt-get install -y libxml2-dev libxslt1-dev build-essential python3-lxml zlib1g-dev \
    && apt-get install -y default-mysql-client default-libmysqlclient-dev \
    && apt-get install -y nginx \
    && wget https://bootstrap.pypa.io/get-pip.py \
    && python3 get-pip.py \
    && rm get-pip.py \
    && pip install -r requirements.txt \
    && apt-get -y install systemctl
ADD site-config /etc/nginx/sites-available/site-config
RUN ln -s /etc/nginx/sites-available/site-config /etc/nginx/sites-enabled/ \
    && rm /etc/nginx/sites-enabled/default

EXPOSE 80

CMD "/app/entrypoint.sh"

This may not be the best method to handle the way I did this, but I use a reverse proxy to access my applications externally and I also needed a way to expose the gunicorn connection properly so that the static files were served in the proper manner.

We need to create our nginx config for properly exposing our container. Create a new file called site-conf in the same directory as your Dockerfile.

server {
  server_name listen 80 default_server;
  access_log  /var/log/nginx/access.log;

  location / {
      proxy_pass http://127.0.0.1:8000;
      proxy_set_header Host $host;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }

  location /staticfiles {
    alias /app/staticfiles/;
  }
}

What this does is proxies our requests to Port 80 to the locally running instance of gunicorn, and it aliases the URL /staticfiles to the /app/staticfiles directy where are static files exists.

Create the entrypoint.sh file in the top directory of your project, this will be used for collecting our static files on every boot, restarting nginx, and starting gunicorn.

python manage.py collectstatic
systemctl restart nginx
gunicorn --bind :8000 --workers 3 core.wsgi:application

Now that we have completed all of our setup for creating our docker image, we need to do one more thing. We need to make sure to generate our requirements.txt file so that our dockerfile can install all of our project dependencies. In the top level of your directory run the below command.

pip freeze >> requirements.txt

If you do not have gunicorn installed already made sure to add it to your requirements.txt file manually. You can see how mine looks below for comparison purposes.

asgiref
backports.zoneinfo
Django
django-crispy-forms
mysqlclient
sqlparse
typing-extensions
gunicorn

Building and Publishing

Now on to the fun part of starting our container and preparing it for product. Before we build our image lets go ahead and created our .env file. This can be placed anywhere in your project, but I prefer to place it in the top level of the project along with my Dockerfile. For all of our parameters that we need, the below will work in this project.

DJANGO_SECRET_KEY=
DEBUG=
DJANGO_ALLOWED_HOSTS=
DJANGO_DATABASE_NAME=
DJANGO_DATABASE_USER=
DJANGO_DATABASE_PASSWORD=
DJANGO_DATABASE_HOST=
DJANGO_DATABASE_PORT=
DJANGO_LOGLEVEL=
DEVELOPMENT_MODE=

Open a terminal session in the same directory as your Dockerfile, and run the below command to build your project.

docker build -t my-app:v0 .

Be patient as this can take a little while to build depending on the complexity of your project. Once the build process completes, we will need to start the container so that we can complete the setup process.

# Complete the SQL Setup portion of the container startup
docker run --env-file .env.local django-store-manager:v0 sh -c "python manage.py makemigrations && python manage.py migrate"

# Launch a new container
docker run -i -t --env-file .env.local -p 80:80 django-store-manager:v0 sh

# Create your super user
python manage.py createsuperuser

Completing the Deployment

The container is built, and ready to go, so what is next? If you want to deploy the image to other servers, you will need to publish it to a container registry, I used my repo on Docker to store the image, but you can choose wherever you want.

# Tag our image
docker tag my-app:v0 docker-hub-username/my-app:v0

# Push it to the container registry
docker push docker-hub-username/my-app:v0

Once the push completes, you can use the sample docker-compose.yml file below to pull the container and stand up the instance on your local machine to verify that it is working.

version: '3.1'

services:

  db:
    image: mysql:latest
    volumes:
      - ./django/mysql:/var/lib/mysql
    ports:
      - 3306:3306
    restart: "unless-stopped"
    environment:
        MYSQL_DATABASE: <database name>
        MYSQL_USER:<user>
        MYSQL_PASSWORD: <password>
        MYSQL_ROOT_PASSWORD: <root password>

  django:
    image: docker-hub-username/my-app:v0
    ports:
      - 8000:80
    restart: "unless-stopped"
    environment:
        DJANGO_SECRET_KEY: <generated secret>
        DEBUG: False
        DJANGO_ALLOWED_HOSTS: "*"
        DJANGO_DATABASE_NAME: <database name>
        DJANGO_DATABASE_USER: <user>
        DJANGO_DATABASE_PASSWORD: <password>
        DJANGO_DATABASE_HOST: <IP Address>
        DJANGO_DATABASE_PORT: <Port>
        DJANGO_LOGLEVEL: info
        DEVELOPMENT_MODE: False
Nathanial Wilson

Nathanial Wilson