buy the book ribbon

Making our App Production-Ready

Our container is working fine but it’s not production-ready. Let’s try to get it there, using the tests to keep us safe.

In a way we’re applying the Red-Green-Refactor cycle to our productionisation process. Our hacky container config got us to Green, and now we’re going to Refactor, working incrementally (just as we would while coding), trying to move from working state to working state, and using the FTs to detect any regressions.

🚧 Warning, chapter under construction 🚧

As part of my work on the third edition of the book, I’m making big changes to the deployment chapters, and this is a very new draft.

This chapter is still very fresh, but the content is all there, so you should be able to follow along.

But as always I really, really need fedback. So please hit me up at [email protected], or via GitHub Issues and Pull Requests.

What We Need to Do

What’s wrong with our hacky container image? A few things: first, we need to host our app on the "normal" port 80 so that people can access it using a regular URL.

Perhaps more importantly, we shouldn’t use the Django dev server for production; it’s not designed for real-life workloads. Instead, we’ll use the popular Gunicorn Python/WSGI server.

In addition, several options in settings.py are currently unacceptable. DEBUG=True, is strongly recommended against for production, we’ll want to set a unique SECRET_KEY, and as we’ll see, other things will come up.

Let’s go through and see if we can fix things one by one.

Switching to Gunicorn

Do you know why the Django mascot is a pony? The story is that Django comes with so many things you want: an ORM, all sorts of middleware, the admin site…​ "What else do you want, a pony?" Well, Gunicorn stands for "Green Unicorn", which I guess is what you’d want next if you already had a pony…​

cmdg

$ pip install gunicorn
Collecting gunicorn
[...]
Successfully installed gunicorn-21.2.0
$ pip freeze | grep -i gunicorn== >> requirements.txt

Gunicorn will need to know a path to a WSGI server, which is usually a function called application. Django provides one in superlists/wsgi.py:

Dockerfile (ch10l002)
CMD gunicorn --bind :8888 superlists.wsgi:application

As in the previous chapter, we can use the docker build && docker run pattern to try out our changes by rebuilding and rerunning our container:

$ docker build -t superlists . && docker run \
  -p 8888:8888 \
  --mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \
  -it superlists

The FTs catch a problem with static files

As we run the functional tests, you’ll see them warning us of a problem, once again. The test for adding list items passes happily, but the test for layout + styling fails. Good job, tests!

$ TEST_SERVER=localhost:8888 python manage.py test functional_tests
[...]
AssertionError: 102.5 != 512 within 10 delta (409.5 difference)
FAILED (failures=1)

And indeed, if you take a look at the site, you’ll find the CSS is all broken, as in Broken CSS.

The reason that we have no CSS is that although the Django dev server will serve static files magically for you, Gunicorn doesn’t.

The site is up, but CSS is broken
Figure 1. Broken CSS

One step forward, one step backward, but once again we’ve identified the problem nice and early. Moving on!

Serving Static Files with Whitenoise

Serving static files is very different from serving dynamically rendered content from Python and Django. There are many ways to serve them in production: you can use a web server like Nginx, or a CDN like Amazon S3, but in our case, the most straightforward thing to do is to use Whitenoise, a Python library expressly designed for serving static[1] files from Python.

Let’s install it first:

$ pip install whitenoise
$ pip freeze | grep -i whitenoise== >> requirements.txt

Then we tell Django to enable it:

src/superlists/settings.py (ch10l003)
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    [...]

And if you take another manual look at your site, things should look much healthier. Let’s rerun our FTs to confirm:

$ TEST_SERVER=localhost:8888 python manage.py test functional_tests --failfast
[...]

...
 ---------------------------------------------------------------------
Ran 3 tests in 10.718s

OK

Phew. Let’s commit that

$ git commit -am"Switch to Gunicorn and Whitenoise"

Using Environment Variables to Adjust Settings for Production

We know there are several things in settings.py that we want to change for production:

  • DEBUG mode is all very well for hacking about on your own server, but it isn’t secure. For example, exposing raw tracebacks to the world is a bad idea.

  • SECRET_KEY is used by Django for some of its crypto—​things like cookies and CSRF protection. It’s good practice to make sure the secret key in production is different from the one in your source code repo, because that code might be visible to strangers. We’ll want to generate a new, random one but then keep it the same for the foreseeable future (find out more in the Django docs).

Development, staging and production sites always have some differences in their configuration. Environment variables are a good place to store those different settings. See "The 12-Factor App".[2]

Setting DEBUG=True and SECRET_KEY

There’s lots of ways you might do this. Here’s what I propose; it may seem a little fiddly, but I’ll provide a little justification for each choice. Let them be an inspiration (but not a template) for your own choices!

superlists/settings.py (ch10l005)
import os
[...]

if "DJANGO_DEBUG_FALSE" in os.environ:  (1)
    DEBUG = False
    SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]  (2)
else:
    DEBUG = True  (3)
    SECRET_KEY = "insecure-key-for-dev"
1 We say we’ll use an environment variable called DJANGO_DEBUG_FALSE to switch debug mode off, and in effect require production settings (it doesn’t matter what we set it to, just that it’s there).
2 And now we say that, if debug mode is off, we require the SECRET_KEY to be set by a second environment variable.
3 Otherwise we fall-back to the insecure, debug mode settings that are useful for Dev.

The end result is that you don’t need to set any env vars for dev, but production needs both to be set explicitly, and it will error if any are missing. I think this gives us a little bit of protection against accidentally forgetting to set one.

Better to fail hard than allow a typo in an environment variable name to leave you running with insecure settings.

Setting environment variables inside the Dockerfile

Now let’s set that environment variable in our Dockerfile using then ENV directive:

Dockerfile (ch10l006)
WORKDIR /src

ENV DJANGO_DEBUG_FALSE=1
CMD gunicorn --bind :8888 superlists.wsgi:application

And try it out…​

$ docker build -t superlists . && docker run \
  -p 8888:8888 \
  --mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \
  -it superlists

[...]
  File "/src/superlists/settings.py", line 22, in <module>
    SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
                 ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
  File "<frozen os>", line 685, in __getitem__
KeyError: 'DJANGO_SECRET_KEY'

Ooops, and I forgot to set said secret key env var, mere seconds after having dreamt it up!

Setting Environment Variables at the Docker Command Line

We’ve said we can’t keep the secret key in our source code, so the Dockerfile isn’t an option; where else can we put it?

For now, we can set it at the command line using the -e flag for docker run:

$ docker build -t superlists . && docker run \
  -p 8888:8888 \
  --mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \
  -e DJANGO_SECRET_KEY=sekrit \
  -it superlists

With that running, we can use our FT again to see if we’re back to a working state.

$ TEST_SERVER=localhost:8888 ./manage.py test functional_tests --failfast
[...]
AssertionError: 'To-Do' not found in 'Bad Request (400)'

ALLOWED_HOSTS is Required When Debug Mode is Turned Off

Not quite! Let’s take a look manually: An ugly 400 error.

An unfriendly page showing 400 Bad Request
Figure 2. An ugly 400 error

We’ve set our two environment variables but doing so seems to have broken things. But once again, by running our FTs frequently, we’re able to identify the problem early, before we’ve changed too many things at the same time. We’ve only changed two settings—which one might be at fault?

Let’s use the "Googling the error message" technique again, with the search terms "django debug false" and "400 bad request".

Well, the very first link in my search results was Stackoverflow suggesting that a 400 error is usually to do with ALLOWED_HOSTS, and the second was the official Django docs, which takes a bit more scrolling, but confirms it (see Search results for "django debug false 400 bad request").

Duckduckgo search results with stackoverflow and django docs
Figure 3. Search results for "django debug false 400 bad request"

ALLOWED_HOSTS is a security setting designed to reject requests that are likely to be forged, broken or malicious because they don’t appear to be asking for your site (HTTP request contain the address they were intended for in a header called "Host").

By default, when DEBUG=True, ALLOWED_HOSTS effectively allows localhost, our own machine, so that’s why it was working OK until now.

There’s more information in the Django docs.

The upshot is that we need to adjust ALLOWED_HOSTS in settings.py. Let’s use another environment variable for that:

superlists/settings.py (ch10l007)
if "DJANGO_DEBUG_FALSE" in os.environ:
    DEBUG = False
    SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
    ALLOWED_HOSTS = [os.environ["DJANGO_ALLOWED_HOST"]]
else:
    DEBUG = True
    SECRET_KEY = "insecure-key-for-dev"
    ALLOWED_HOSTS = []

This is a setting that we want to change, depending on whether our docker image is running locally, or on a server, so we’ll use the -e flag again:

$ docker build -t superlists . && docker run \
    -p 8888:8888 \
    --mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \
    -e DJANGO_SECRET_KEY=sekrit \
    -e DJANGO_ALLOWED_HOST=localhost \
    -it superlists

Collectstatic is Required when Debug is Turned Off

An FT run (or just looking at the site) reveals that we’ve had a regression in our static files.

$ TEST_SERVER=localhost:8888 python manage.py test functional_tests
[...]
AssertionError: 102.5 != 512 within 10 delta (409.5 difference)
FAILED (failures=1)

We saw this before when switching from the Django dev server to Gunicorn, so we introduced Whitenoise. Similarly, when we switch DEBUG off, Whitenoise stops automagically finding static files in our code, and instead we need to run collectstatic:

Dockerfile (ch10l008)
WORKDIR /src

RUN python manage.py collectstatic

ENV DJANGO_DEBUG_FALSE=1
CMD gunicorn --bind :8888 superlists.wsgi:application

Well, it was fiddly, but that should get us to passing tests!

$ TEST_SERVER=localhost:8888 python manage.py test functional_tests
[...]
OK

We have a container that we’re ready to ship to production!

Find out how in the next exciting installment…​

Configuring logging

One last thing we’ll want to do is make sure that we can get logs out of our server. If things go wrong, we want to be able to get to the tracebacks, and as we’ll soon see, switching DEBUG off means that Django’s default logging configuration changes.

Provoking a deliberate error

To test this, we’ll provoke a deliberate error by deleting the database file.

$ rm src/db.sqlite3 && touch src/db.sqlite3
If you use the -v option for docker run to mount a nonexistent path, Docker will create a new folder at that path. This is why we do the touch db.sqlite3 so that we have a placeholder empty file where the datbase should be. Otherwise we get a folder instead of an empty file and django gets even more confused; if you ever see an error saying chown: permission denied, that’s probably it. Do a rm -rf src/db.sqlite3 && touch src/db.sqlite3 to fix it.

Now if you run the tests, you’ll see they fail;

$ TEST_SERVER=localhost:8888 ./src/manage.py test functional_tests --failfast
[...]

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]; [...]

And you might spot in the browser that we just see a minimal error page, with no debug info (try it manually if you like):

A minimal error page saying just Server error (500)
Figure 4. Minimal default server error 500

But if you look in your docker terminal, you’ll see there is no traceback:

[2024-02-28 10:41:53 +0000] [7] [INFO] Starting gunicorn 21.2.0
[2024-02-28 10:41:53 +0000] [7] [INFO] Listening at: http://0.0.0.0:8888 (7)
[2024-02-28 10:41:53 +0000] [7] [INFO] Using worker: sync
[2024-02-28 10:41:53 +0000] [8] [INFO] Booting worker with pid: 8

Where have the tracebacks gone? You might have been expecting that the django debug page and its tracebacks would disappear from our web browser, but it’s more of shock to see that they are no longer appearing in the terminal either! If you’re like me you might find yourself wondering if we really did see them earlier and starting to doubt your own sanity. But the explanation is that Django’s default logging configuration changes when DEBUG is turned off:

This means we need to interact with the standard library’s logging module, unfortunately one of the most fiddly parts of the Python standard library[3].

Here’s pretty much the simplest possible logging config which just prints everything to the console (ie standard out).

src/superlists/settings.py (ch10l009)
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "console": {"class": "logging.StreamHandler"},
    },
    "loggers": {
        "root": {"handlers": ["console"], "level": "INFO"},
    },
}

Rebuild and restart our container, try the FT again (or submitting a new list item manually) and we now should see a clear error message:

Internal Server Error: /lists/new
Traceback (most recent call last):
[...]
  File "/src/lists/views.py", line 10, in new_list
    nulist = List.objects.create()
             ^^^^^^^^^^^^^^^^^^^^^
[...]
  File "/venv/lib/python3.12/site-packages/django/db/backends/sqlite3/base.py",
  line 328, in execute
    return super().execute(query, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
django.db.utils.OperationalError: no such table: lists_list

Re-create the datase with ./src/manage.py migrate and we’ll be back to a working state.

Don’t forget to commit our changes to settings.py, and I think we can call it job done! We’ve at least touched on many or most of the things you might need to think about when considering production-readiness, we’ve worked in small steps and used our tests all the way along, and we’re now ready to deploy our container to a real server!

Production-Readiness Config

A few things to think about when trying to prepare a production-ready configuration:

Don’t use the Django dev server in production

Something like Gunicorn or uWSGI is a better tool for running Django; it will let you run multiple workers, for example.

Decide how to serve your static files

Static files aren’t the same kind of things as the dynamic content that comes from Django and your webapp, so they need to be treated differently. WhiteNoise is just one example of how you might do that.

Check your settings.py for dev-only config

DEBUG=True, ALLOWED_HOSTS and SECRET_KEY are the ones we came across, but you will probably have others (we’ll see more when we start to send emails from the server).

Change things one at a time and rerun your tests frequently

Whenever we make a change to our server configuration, we can rerun the test suite, and either be confident that everything works as well as it did before, or find out immediately if we did something wrong.

Think about logging and observability

When things go wrong, you need to be able to find out what happened. At a minimum you need a way of getting logs and tracebacks out of your server, and in more advanced environments you’ll want to think about metrics and tracing too. But we can’t cover all that in this book!


1. Believe it or not, this pun didn’t actually hit me until I was rewriting this chapter. For 10 years it was right under my nose. I think that makes it funnier actually.
2. Another common way of handling this is to have different versions of settings.py for dev and prod. That can work fine too, but it can get confusing to manage. Environment variables also have the advantage of working for non-Django stuff too…​
3. It’s not necessarily for bad reasons, but it is all very Java-ey and enterprisey. I mean, yes, separating the concepts of handlers and loggers and filters, and making it all configurable in a nested hierarchy is all well and good and covers every possible use case, but sometimes you just wanna say "just print stuff to stdout pls", and you wish that configuring the simplest thing was a little easier

Comments