My Favorite Docker Setup for Django Development

Note: This article was written in 2019. I’ve updated what I can see as being outdated or outdate-able, so it should be pretty reliable, but I make no promises. If you find something that’s wrong, please let me know.

At my company, we’ve been developing a common Docker workflow for all of our microservices to use. I really want us to get it to a state where we can open source it but that seems to be a long way off. I’ve slowly been adapting parts of it for local, personal development and I think it’s to a place where I can share it with all of you.

I’m not going to go into what Docker is, how to install it, or what all you can do Docker. Instead, I’m just going to focus on the files you need to create or edit and the commands you’ll run. Alright, let’s jump in!

Django project

I like to keep a virtual environment around to hold packages for my editor and for local commands (like the excellent pip-tools). Create yourself a virtual environment, activate it, install Django, and start a new Django project, more-or-less like so:

$ mkdir my_project && cd my_project
$ python3 -m venv venv
$ source venv/bin/activate
$ pip install Django
$ django-admin startproject app

I’ll often just call my project project or app but feel free to use a more descriptive name. Also, at this point, do any project configurations that you need to, like setting up a git repo.

Docker

There are lots of different ways to build your Dockerfile and to configure the resulting images. If you have some preferred method that I’m not using here, feel free to use your method (and tell me about it!). I am not a Docker expert, so buyer beware!

file: Dockerfile

FROM python:<USE WHAT'S CURRENT>

RUN apt-get update && \
  apt-get install -y && \
  pip3 install uwsgi

COPY ./app /opt/app

RUN python3 -m pip install --upgrade pip
RUN pip3 install -r /opt/app/requirements.txt

ENV DJANGO_ENV=prod
ENV PYTHONUNBUFFERED 1
ENV PYTHONPATH /opt/app/

EXPOSE 8000

CMD ["uwsgi", "--ini", "/opt/app/uwsgi.ini"]

Starting from the top, this Dockerfile starts by using the python:3.7 image, which gives you a recent Debian version with Python installed. Next, it automatically updates the package list and installs any necessary updates. Then it installs uwsgi which is how we’ll run our Django project.

The COPY line will copy your project, app, into /opt/app in your Docker image. This path is completely customizable if you have a preferred location for your projects to live. Keep this path in mind, though, as you’ll need it later on.

The two RUN lines should be fairly understandable if you’ve used Python for awhile. The first upgrades the existing pip installation. The second installs all of your project’s requirements (if you haven’t, now is a good time to run pip freeze > app/requirements.txt so you’ll have the necessary requirements.txt file).

The three ENV lines set up some environment variables. The first says that this is a production environment (you’d override this in a development-only docker-compose.override.yml file). The second prevents Python from buffering output. And the last one adds /opt/app, where we’re putting our project, onto the PYTHONPATH. I’m not certain this last one is needed (and I’ll leave using the first one up to you), but it seems to make a test discovery a little easier.

Next, we expose port 8000, Django’s default port. This makes debugging easier but we won’t rely on port 8000 for development or deployment. And, lastly, we run uwsgi with a CMD statement. That statement mentions a uwsgi.ini file. What’s in that?

file: app/uwsgi.ini

[uwsgi]
http-socket = :8000
chdir = /opt/app
module = app.wsgi
master = 1
processes = 2
threads = 2
py-autoreload = 3

Much like with Docker, I am not a uwsgi expert, but this seems to work really well. Let me walk you through it as well.

http-socket is a nice little goody we get for using a modern uwsgi. This basically tells uwsgi to bind itself to port 8000 for incoming HTTP requests and to respond to them.

The chdir line changes the directory that uwsgi is operating in and module tells it what module holds the wsgi process. If you picked a different directory in your Dockerfile or a different project name, you’ll need to update both of these directives.

The next three lines, master, processes, and threads, control how uwsgi spawns processes and threads. This setup works for my machines but you may need to tweak some numbers depending on your machine. You can read a lot more about them in the uwsgi docs.

Finally, the py-autoreload directive tells uwsgi to restart when it detects a change to a file. The 3 controls how many seconds should elapse between checks. You can change this speed if your system is really fast or slow but 3 seems to be a good default.

Alright, at this point, you should be able to do docker build . to build your image. One of the last lines will contain the image name that was just built. In my run while writing this, it was bbf8ba9d356d. Then you can use docker run -p 8000:8000 bbf8ba9d356d to start it and bind port 8000 on the running image to port 8000 on your local machine. You should be able to visit [http://localhost:8000] and see the “Welcome to Django” page. If you see an error, that’s alright, ‘cause we’re not done. Hit ctrl-c to end the process (you may need to check docker ps to make sure it ended. If it didn’t, you can stop it with docker stop <container id>).

docker-compose

Most Django projects need more than just a wsgi process. So let’s add a few extra bits like nginx and a database. We’ll configure all of this with a new file, docker-compose.yml. Because this file is pretty long, I’m going to show and explain it section-by-section.

file: docker-compose.yml

version: "3"

volumes:
  pg_data: {}
  pg_backups: {}

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge

services:

This section isn’t very exciting but it’s necessary. First we define the version of docker-compose that this file should conform to. We’re using "3" which is the newest version at the time of writing.

In the volumes section, we define two persistent volumes that Postgres, our database, will use to store data and backups.

And, lastly, in the networks section, we create two networks. We’ll use these to keep communication between containers separate. You can ignore this part if you want, I just like it for some extra organization. If you have many services, like in a microservice architecture, you can use these networks to make service- to-service communication easier, but that’s beyond the scope of this article.

Our next section is services and it makes up the bulk of the file. Let me show you each service that we’ll be running. All of these snippets should be indented inside the services block.

nginx service

file: docker-compose.yml

nginx:
  image: nginx:<USE WHAT'S CURRENT>
  ports:
    - "80:80"
    - "443:443"
  volumes:
    - ./data/nginx:/etc/nginx/conf.d
  command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"'''
  networks:
    - frontend
  depends_on:
    - app

First is our nginx section, which will give us the excellent nginx server. We tell Docker to use the nginx image, version TBD, you’ll need to find a recent release. This will give us a small Linux distribution with nginx already installed. Next we expose two ports, 80 and 443, so we can reach nginx from our web browser. The 443 port is a bit of forward-thinking for HTTPS connections. I’ll leave the HTTPS certificate and configuration to you, though.

Next, we mount a local directory, ./data/nginx, into the configuration directory for nginx. This lets us easily override configuration files.

Then we have a command that will reload nginx every 6 hours. We don’t really need this in this example but it’s handy when you’re running something like certbot and want to be sure you have up-to-date certificates.

Our last two directives tell nginx to join the frontend network and that it should start the app service if it’s not already running.

Before I forget, let’s look at that nginx configuration file. Mine is pretty simple at this point.

file: data/nginx/app.conf

upstream app {
    server app:8000;
}

server {
    listen 80;
    server_name localhost;

    location / {
        proxy_pass http://app;
        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;
    }
}

If you’re comfortable with nginx configuration, you probably understand this file, but I’ll go over it for those of you, like me, who need an introduction or refresher from time to time.

First, we define an upstream server named app. This server lives at the network location of app and uses port 8000. Our Django service will be named app and Django listens on port 8000, so this should be a good match.

The location block defines what happens when the server gets a request to / or any deeper paths (/foo or /foo/bar/baz.html, for example). We tell nginx to pass those requests to the app upstream and we set a few headers. These headers will make sure Django knows what host made the request and provide the IP address for the actual requester (instead of nginx).

database service

Now we can set up our database. I like Postgres the best but if you love another database, feel free to use it.

file: docker-compose.yml

database:
  image: postgres:<USE WHAT'S CURRENT>
  ports:
    - "5432:5432"
  volumes:
    - pg_data:/var/lib/postgresql/data
    - pg_backups:/pg_backups
  env_file:
    - data/postgres/database_env
  networks:
    - backend

This should look quite a bit like the nginx section from above. Again, we’re using an image so we don’t have to build all of this ourselves. In this case, it’s postgres:<CURRENT VERSION> which will give us an install of Postgres. Next, we expose port 5432. If you don’t want to use a local database GUI, you can leave this out (but I think you need to add expose: 5432 so other services can see your database).

Then we mount our two volumes that we defined earlier into the image. I’m not including any information on how to create, store, and use your Postgres backups, but you have a persistent volume, so figure that out :).

The last directive, networks, you’ve seen before. We’re connecting our database container to our backend network. Since nginx and database don’t share a network, they shouldn’t be able to see each other.

But what about that env_file section? That allows you to specify environment variables in a separate file. If you don’t track this file in version control, you can store sensitive secrets in it. There are better ways to store secrets, though. Let me show you what’s in my file:

file: data/postgres/database_env

POSTGRES_USER=database_role
POSTGRES_PASSWORD=database_password
POSTGRES_DB=project_database

This sets up three environment variables. POSTGRES_USER allows you to add a new role to Postgres. POSTGRES_PASSWORD sets the password for either the default postgres user or the user you specified in POSTGRES_USER. Lastly, POSTGRES_DB will create a new database, owned by POSTGRES_USER.

app service

This service is where your Django project lives and runs. It’s not that different from the previous services but it’ll use our Dockerfile-produced image instead of a pre-built image.

file: docker-compose.yml

app:
  build: .
  restart: always
  ports:
    - "8000:8000"
  depends_on:
    - database
  networks:
    - frontend
    - backend
  volumes:
    - ./app:/opt/app

The first difference is the build directive. This tells Docker to build the Dockerfile in the current directory and use the resulting image for this service’s containers. The restart directive says to restart this container if it crashes for some reason. And ports, again, exposes port 8000 to us for debugging purposes or just to connect to if you didn’t want to start nginx. depends_on and networks you’ve seen before, but this time we connect to both networks.

Finally, the volumes directive will mount your local ./app directory into the container at /opt/app (change this if you want your project mounted elsewhere). This will allow you to edit files locally, just like you’re probably used to, but have the container notice the changes and restart the uwsgi process. Notice that we are not telling Docker how to run our Django project. We’re expecting it to be available because of the uwsgi command in the Dockerfile.

Without this volumes mount, you would have to rebuild your container every time you made a change to a file. As you can imagine, this gets old really fast.

manage service

This service, and the next one, are the entire reason I wrote this article. I know, I went and buried them at the bottom. These two, though, make a few things much easier and nicer when you’re using Docker.

file: docker-compose.yml

manage:
  build: .
  command: shell
  entrypoint: /usr/local/bin/python3 /opt/app/manage.py
  networks:
    - backend
  volumes:
    - ./app:/opt/app
  depends_on:
    - database

I’m not going to cover build, networks, volumes, or depends_on since you’ve seen them before. Besides, most of the magic is in command and entrypoint.

Let’s start with entrypoint, even though it comes second. This directive tells Docker where to start running commands. In this case, we want to use our Python install to run our project’s manage.py. Then, if no commands are given, it’ll run whatever is in the command directive. So, by default, this container runs /usr/local/bin/python3 /opt/app/manage.py shell, which will put us into a Django shell. If you had django-extensions installed, you could change this to shell_plus, for example.

Jumping ahead just slightly, this service allows you to do commands like docker-compose run --rm manage makemigrations to make any new migrations. This means you’ll use your Docker-based Python and Django installation to make your migrations, which removes the need for that local virtual environment (although I still keep one around).

tests service

And, finally, our tests service. This service will let us run tests in a container just like our app one. Again, this will make running test easier and make sure that our tests run in an environment very similar to the project itself. It’s a win-win in my book!

file: docker-compose.yml

tests:
  build: .
  command: /opt/app/
  entrypoint: /usr/local/bin/py.test -W error
  restart: "no"
  networks:
    - backend
  volumes:
    - ./app:/opt/app
  depends_on:
    - database
    - app

Again, I’m going to skip build, networks, volumes, and depends_on. You’ve seen restart before but this time it’s set to "no". This will let the container stop and stay stopped until the next time we run it. We want this behavior because there’s no reason to keep the container running after the tests finish their run.

For this service, entrypoint starts up pytest, telling it to treat warnings like an error (you can turn this off if you want) and command points it to our project so it can discover tests throughout the project.

We do need to add a pytest.ini file, though, to configure pytest just a bit. You’ll also want to add pytest and pytest-django to your requirements.txt.

file: app/pytest.ini

[pytest]
norecursedirs = __pycache__
DJANGO_SETTINGS_MODULE = app.settings
python_file = tests.py test_*.py *_tests.py

This configuration tells pytest not to look through any __pycache__ directories for tests. It also sets where Django’s settings live for pytest-django and which files to look for to find tests.

Much like the manage service, you can use docker-compose run --rm tests and Docker will run all of your tests and give you the results. Feel free to add other plugins like pytest-cov or pytest-xdist to make testing more complete.

Running it all

Assuming everything is entered correctly and I’m not completely misguided, you should now be able to run docker-compose build in the directory that houses your docker-compose.yml file. This will download the python, nginx, and postgres images, build your local image, and install your dependencies.

If you don’t get any errors, run docker-compose up and you should see all of your containers start up. Since your tests container is included by default, Docker will run all of your tests for you. You can add on the -D argument to put your containers in the background, but I usually leave that off. Open up http://localhost and you should see the welcome page.

Now you need to migrate and create a superuser. docker-compose run --rm manage migrate will run all of your existing migrations from Django itself. And docker-compose run --rm manage createsuperuser will give you the necessary prompts to create a new superuser.

Time to start building your next great Django project!