├── my_project ├── __init__.py ├── wsgi.py ├── settings │ ├── deploy.py │ └── __init__.py └── urls.py ├── _config.yml ├── .gitignore ├── Makefile ├── requirements.txt ├── setup.cfg ├── .dockerignore ├── docker-entrypoint.sh ├── docker-compose.yml ├── manage.py ├── .travis.yml ├── check.py ├── Dockerfile ├── README.rst.j2 └── README.rst /my_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | db.sqlite3 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := readme 2 | 3 | 4 | readme: 5 | j2 README.rst.j2 > README.rst 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=2.2,<2.3 2 | uwsgi>=2.0,<2.1 3 | dj-database-url>=0.5,<0.6 4 | psycopg2>=2.8,<2.9 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | ignore=W503 4 | 5 | [isort] 6 | line_length=120 7 | multi_line_output=2 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .travis.yml 2 | __pycache__ 3 | check.py 4 | db.sqlite3 5 | docker-compose.yml 6 | Dockerfile 7 | setup.cfg 8 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | until psql $DATABASE_URL -c '\l'; do 5 | >&2 echo "Postgres is unavailable - sleeping" 6 | sleep 1 7 | done 8 | 9 | >&2 echo "Postgres is up - continuing" 10 | 11 | if [ "x$DJANGO_MANAGEPY_MIGRATE" = 'xon' ]; then 12 | python manage.py migrate --noinput 13 | fi 14 | 15 | exec "$@" 16 | -------------------------------------------------------------------------------- /my_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for my_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my_project.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | db: 5 | environment: 6 | POSTGRES_DB: app_db 7 | POSTGRES_USER: app_user 8 | POSTGRES_PASSWORD: changeme 9 | restart: always 10 | image: postgres:12 11 | expose: 12 | - "5432" 13 | app: 14 | environment: 15 | DATABASE_URL: postgres://app_user:changeme@db/app_db 16 | DJANGO_MANAGEPY_MIGRATE: "on" 17 | build: 18 | context: . 19 | dockerfile: ./Dockerfile 20 | links: 21 | - db:db 22 | ports: 23 | - "8000:8000" 24 | -------------------------------------------------------------------------------- /my_project/settings/deploy.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import dj_database_url 4 | 5 | from . import * # noqa: F403 6 | 7 | # This is NOT a complete production settings file. For more, see: 8 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ 9 | 10 | DEBUG = False 11 | 12 | ALLOWED_HOSTS = ['localhost'] 13 | 14 | DATABASES['default'] = dj_database_url.config(conn_max_age=600) # noqa: F405 15 | 16 | STATIC_ROOT = os.path.join(BASE_DIR, 'static') # noqa: F405 17 | 18 | STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' 19 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my_project.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - "3.7" 5 | services: 6 | - docker 7 | 8 | env: 9 | global: 10 | - GREP_TIMEOUT=10 11 | 12 | # Update Docker Engine 13 | before_install: 14 | - sudo apt-get update 15 | - sudo apt-get install -qy -o Dpkg::Options::="--force-confold" docker-ce coreutils 16 | 17 | install: 18 | - pip install requests==2.21.0 beautifulsoup4==4.7.1 flake8==3.7.7 isort==4.3.16 j2cli==0.3.10 19 | 20 | before_script: 21 | - docker-compose up --build -d 22 | - docker-compose ps 23 | - timeout $GREP_TIMEOUT grep -m 1 'spawned uWSGI http 1' <(docker-compose logs --follow app 2>&1) 24 | 25 | script: 26 | - python3 check.py 27 | - flake8 28 | - isort --recursive --check-only --diff 29 | - make readme 30 | - git diff --exit-code 31 | 32 | after_script: 33 | - docker-compose logs 34 | -------------------------------------------------------------------------------- /my_project/urls.py: -------------------------------------------------------------------------------- 1 | """my_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/dev/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /check.py: -------------------------------------------------------------------------------- 1 | "Run some basic tests against the docker-compose app" 2 | import logging 3 | from urllib.parse import urljoin 4 | 5 | import requests 6 | from bs4 import BeautifulSoup 7 | 8 | logging.basicConfig(level=logging.DEBUG) 9 | 10 | BASE_URL = 'http://localhost:8000/' 11 | 12 | s = requests.Session() 13 | 14 | # uWSGI should return a 400 error for requests with a bad Host header 15 | r = s.get(urljoin(BASE_URL, ''), headers={'Host': 'badhost.com'}) 16 | assert r.status_code == 400, r.status_code 17 | # uWSGI just returns an empty response. If the request makes it to Django 18 | # (which it shouldn't), this will be b'

Bad Request (400)

'. 19 | assert r.content == b'', r.content 20 | 21 | # Our project doesn't have a homepage URL 22 | r = s.get(urljoin(BASE_URL, '')) 23 | assert r.status_code == 404, r.status_code 24 | 25 | # We should still be able to get to the admin 26 | r = s.get(urljoin(BASE_URL, 'admin/')) 27 | assert r.status_code == 200, r.status_code 28 | 29 | # Which, in turn, should have some CSS files we can try to download 30 | soup = BeautifulSoup(r.content, features="html.parser") 31 | for link_href in [l.get('href') for l in soup.find_all('link')]: 32 | # If static files fail to download, uWSGI must not be set up properly to 33 | # serve them. 34 | r = s.get(urljoin(BASE_URL, link_href)) 35 | assert r.status_code == 200, \ 36 | 'r.status_code=%s, link_href=%s' % (r.status_code, link_href) 37 | # If there's no 'Expires' header, uWSGI probably didn't get built with 38 | # regexp support (likely due to a missing system package). 39 | assert 'Expires' in r.headers, \ 40 | 'r.headers=%s, link_href=%s' % (r.headers, link_href) 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-slim 2 | 3 | # Create a group and user to run our app 4 | ARG APP_USER=appuser 5 | RUN groupadd -r ${APP_USER} && useradd --no-log-init -r -g ${APP_USER} ${APP_USER} 6 | 7 | # Install packages needed to run your application (not build deps): 8 | # mime-support -- for mime types when serving static files 9 | # postgresql-client -- for running database commands 10 | # We need to recreate the /usr/share/man/man{1..8} directories first because 11 | # they were clobbered by a parent image. 12 | RUN set -ex \ 13 | && RUN_DEPS=" \ 14 | libpcre3 \ 15 | mime-support \ 16 | postgresql-client \ 17 | " \ 18 | && seq 1 8 | xargs -I{} mkdir -p /usr/share/man/man{} \ 19 | && apt-get update && apt-get install -y --no-install-recommends $RUN_DEPS \ 20 | && rm -rf /var/lib/apt/lists/* 21 | 22 | # Copy in your requirements file 23 | ADD requirements.txt /requirements.txt 24 | 25 | # OR, if you're using a directory for your requirements, copy everything (comment out the above and uncomment this if so): 26 | # ADD requirements /requirements 27 | 28 | # Install build deps, then run `pip install`, then remove unneeded build deps all in a single step. 29 | # Correct the path to your production requirements file, if needed. 30 | RUN set -ex \ 31 | && BUILD_DEPS=" \ 32 | build-essential \ 33 | libpcre3-dev \ 34 | libpq-dev \ 35 | " \ 36 | && apt-get update && apt-get install -y --no-install-recommends $BUILD_DEPS \ 37 | && pip install --no-cache-dir -r /requirements.txt \ 38 | \ 39 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $BUILD_DEPS \ 40 | && rm -rf /var/lib/apt/lists/* 41 | 42 | # Copy your application code to the container (make sure you create a .dockerignore file if any large files or directories should be excluded) 43 | RUN mkdir /code/ 44 | WORKDIR /code/ 45 | ADD . /code/ 46 | 47 | # uWSGI will listen on this port 48 | EXPOSE 8000 49 | 50 | # Add any static environment variables needed by Django or your settings file here: 51 | ENV DJANGO_SETTINGS_MODULE=my_project.settings.deploy 52 | 53 | # Call collectstatic (customize the following line with the minimal environment variables needed for manage.py to run): 54 | RUN DATABASE_URL='' python manage.py collectstatic --noinput 55 | 56 | # Tell uWSGI where to find your wsgi file (change this): 57 | ENV UWSGI_WSGI_FILE=my_project/wsgi.py 58 | 59 | # Base uWSGI configuration (you shouldn't need to change these): 60 | ENV UWSGI_HTTP=:8000 UWSGI_MASTER=1 UWSGI_HTTP_AUTO_CHUNKED=1 UWSGI_HTTP_KEEPALIVE=1 UWSGI_LAZY_APPS=1 UWSGI_WSGI_ENV_BEHAVIOR=holy 61 | 62 | # Number of uWSGI workers and threads per worker (customize as needed): 63 | ENV UWSGI_WORKERS=2 UWSGI_THREADS=4 64 | 65 | # uWSGI static file serving configuration (customize or comment out if not needed): 66 | ENV UWSGI_STATIC_MAP="/static/=/code/static/" UWSGI_STATIC_EXPIRES_URI="/static/.*\.[a-f0-9]{12,}\.(css|js|png|jpg|jpeg|gif|ico|woff|ttf|otf|svg|scss|map|txt) 315360000" 67 | 68 | # Deny invalid hosts before they get to Django (uncomment and change to your hostname(s)): 69 | ENV UWSGI_ROUTE_HOST="^(?!localhost:8000$) break:400" 70 | 71 | # Change to a non-root user 72 | USER ${APP_USER}:${APP_USER} 73 | 74 | # Uncomment after creating your docker-entrypoint.sh 75 | ENTRYPOINT ["/code/docker-entrypoint.sh"] 76 | 77 | # Start uWSGI 78 | CMD ["uwsgi", "--show-config"] 79 | -------------------------------------------------------------------------------- /my_project/settings/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for my_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2rc1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/dev/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '*_y(zx!8d_b_tlbv*(j0=z=__5dsyv-36#$^@-*x5r%b#l78uw' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | ] 41 | 42 | MIDDLEWARE = [ 43 | 'django.middleware.security.SecurityMiddleware', 44 | 'django.contrib.sessions.middleware.SessionMiddleware', 45 | 'django.middleware.common.CommonMiddleware', 46 | 'django.middleware.csrf.CsrfViewMiddleware', 47 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 48 | 'django.contrib.messages.middleware.MessageMiddleware', 49 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 50 | ] 51 | 52 | ROOT_URLCONF = 'my_project.urls' 53 | 54 | TEMPLATES = [ 55 | { 56 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 57 | 'DIRS': [], 58 | 'APP_DIRS': True, 59 | 'OPTIONS': { 60 | 'context_processors': [ 61 | 'django.template.context_processors.debug', 62 | 'django.template.context_processors.request', 63 | 'django.contrib.auth.context_processors.auth', 64 | 'django.contrib.messages.context_processors.messages', 65 | ], 66 | }, 67 | }, 68 | ] 69 | 70 | WSGI_APPLICATION = 'my_project.wsgi.application' 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/dev/ref/settings/#databases 75 | 76 | DATABASES = { 77 | 'default': { 78 | 'ENGINE': 'django.db.backends.sqlite3', 79 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 80 | } 81 | } 82 | 83 | 84 | # Password validation 85 | # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators 86 | 87 | AUTH_PASSWORD_VALIDATORS = [ 88 | { 89 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 90 | }, 91 | { 92 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 93 | }, 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 96 | }, 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 99 | }, 100 | ] 101 | 102 | 103 | # Internationalization 104 | # https://docs.djangoproject.com/en/dev/topics/i18n/ 105 | 106 | LANGUAGE_CODE = 'en-us' 107 | 108 | TIME_ZONE = 'UTC' 109 | 110 | USE_I18N = True 111 | 112 | USE_L10N = True 113 | 114 | USE_TZ = True 115 | 116 | 117 | # Static files (CSS, JavaScript, Images) 118 | # https://docs.djangoproject.com/en/dev/howto/static-files/ 119 | 120 | STATIC_URL = '/static/' 121 | -------------------------------------------------------------------------------- /README.rst.j2: -------------------------------------------------------------------------------- 1 | This is a DRAFT (possibly including untested, yet-to-be released) edits to the blog post published here: https://www.caktusgroup.com/blog/2017/03/14/production-ready-dockerfile-your-python-django-app/ 2 | 3 | **Have a suggested tweak or fix?** Feel free to submit a PR. 4 | 5 | 6 | A Production-ready Dockerfile for Your Python/Django App 7 | ======================================================== 8 | 9 | **Update (October 29, 2019):** I updated this post with more recent Django and Postgres versions, to use Python and pip directly in the container (instead of in a separate virtual environment, which was unnecessary), and switched to a non-root user via Docker instead of uWSGI. 10 | 11 | Docker has matured a lot since it was released. We've been watching it closely at Caktus, and have been thrilled by the adoption -- both by the community and by service providers. As a team of Python and Django developers, we're always searching for best of breed deployment tools. Docker is a clear fit for packaging the underlying code for many projects, including the Python and Django apps we build at Caktus. 12 | 13 | This post also includes an `accompanying GitHub repo `_. 14 | 15 | Technical Overview 16 | ------------------ 17 | 18 | There are many ways to containerize a Python/Django app, no one of which could be considered "the best." That being said, I think the following approach provides a good balance of simplicity, configurability, and container size. The specific tools I use are: `Docker `_ (of course), the `python:3.7-slim `_ Docker image (based on Debian Stretch), and `uWSGI `_. 19 | 20 | In a previous version of this post, I used `Alpine Linux `_ as the base image for this 21 | Dockerfile. But now, I'm switching to a Debian- and glibc-based image, because I found `an inconvenient workaround `_ was required for `musl libc `_'s ``strftime()`` implementation. The Debian-based "slim" images are still relatively small; the bare-minimum image described below increases from about 170 MB to 227 MB (~33%) when switching from ``python:3.7-alpine`` to ``python:3.7-slim`` (and updating all the corresponding system packages). 22 | 23 | There are many WSGI servers available for Python, and we use both Gunicorn and uWSGI at Caktus. A couple of the benefits of uWSGI are that (1) it's almost entirely configurable through environment variables (which fits well with containers), and (2) it includes `native HTTP support `_, which can circumvent the need for a separate HTTP server like Apache or Nginx. 24 | 25 | 26 | The Dockerfile 27 | -------------- 28 | 29 | Without further ado, here's a production-ready ``Dockerfile`` you can use as a starting point for your project (it should be added in your top level project directory, next to the ``manage.py`` script provided by your Django project): 30 | 31 | .. code-block:: docker 32 | {# re-comment out the lines that we've uncommented in the sample files, for testing purposes -#} 33 | {% filter replace("ENTRYPOINT", "# ENTRYPOINT") -%} 34 | {% filter replace("ENV UWSGI_ROUTE_HOST", "# ENV UWSGI_ROUTE_HOST") -%} 35 | {% filter indent(width=4) %} 36 | {% include "Dockerfile" %} 37 | {%- endfilter %} 38 | {%- endfilter %} 39 | {%- endfilter %} 40 | We extend from the "slim" flavor of the official Docker image for Python 3.7, install a few dependencies for running our application (i.e., that we want to keep in the final version of the image), copy the folder containing our requirements files to the container, and then, in a single line, (a) install the build dependencies needed, (b) ``pip install`` the requirements themselves (edit this line to match the location of your requirements file, if needed), (c) remove the C compiler and any other OS packages no longer needed, and (d) remove the package lists since they're no longer needed. It's important to keep this all on one line so that Docker will cache the entire operation as a single layer. 41 | 42 | Next, we copy our application code to the image, set some default environment variables, and run ``collectstatic``. Be sure to change the values for ``DJANGO_SETTINGS_MODULE`` and ``UWSGI_WSGI_FILE`` to the correct paths for your application (note that the former requires a Python package path, while the latter requires a file system path). 43 | 44 | A few notes about other aspects of this Dockerfile: 45 | 46 | * I only included a minimal set of OS dependencies here. If this is an established production app, you'll most likely need to visit https://packages.debian.org, search for the Debian package names of the OS dependencies you need, including the ``-dev`` supplemental packages as needed, and add them either to ``RUN_DEPS`` or ``BUILD_DEPS`` in your Dockerfile. 47 | * Adding ``--no-cache-dir`` to the ``pip install`` command saves a additional disk space, as this prevents ``pip`` from `caching downloads `_ and `caching wheels `_ locally. Since you won't need to install requirements again after the Docker image has been created, this can be added to the ``pip install`` command. Thanks Hemanth Kumar for this tip! 48 | * uWSGI contains a lot of optimizations for running many apps from the same uWSGI process. These optimizations aren't really needed when running a single app in a Docker container, and can `cause issues `_ when used with certain 3rd-party packages. I've added ``UWSGI_LAZY_APPS=1`` and ``UWSGI_WSGI_ENV_BEHAVIOR=holy`` to the uWSGI configuration to provide a more stable uWSGI experience (the latter will be the default in the next uWSGI release). 49 | * The ``UWSGI_HTTP_AUTO_CHUNKED`` and ``UWSGI_HTTP_KEEPALIVE`` options to uWSGI are needed in the event the container will be hosted behind an Amazon Elastic Load Balancer (ELB), because Django doesn't set a valid ``Content-Length`` header by default, unless the ``ConditionalGetMiddleware`` is enabled. See `the note `_ at the end of the uWSGI documentation on HTTP support for further detail. 50 | 51 | 52 | Requirements and Settings Files 53 | ------------------------------- 54 | 55 | Production-ready requirements and settings files are outside the scope of this post, but you'll need to include a few things in your requirements file(s), if they're not there already:: 56 | {% filter indent(width=4) %} 57 | {% include "requirements.txt" %} 58 | {%- endfilter %} 59 | I didn't pin these to specific versions here to help future-proof this post somewhat, but you'll likely want to pin these (and other) requirements to specific versions so things don't suddenly start breaking in production. Of course, you don't have to use any of these packages, but you'll need to adjust the corresponding code elsewhere in this post if you don't. 60 | 61 | My ``deploy.py`` settings file looks like this: 62 | 63 | .. code-block:: python 64 | {% filter indent(width=4) %} 65 | {% include "my_project/settings/deploy.py" %} 66 | {%- endfilter %} 67 | This bears repeating: This is **not** a production-ready settings file, and you should review `the checklist `_ in the Django docs (and run ``python manage.py check --deploy --settings=my_project.settings.deploy``) to ensure you've properly secured your production settings file. 68 | 69 | 70 | Building and Testing the Container 71 | ---------------------------------- 72 | 73 | Now that you have the essentials in place, you can build your Docker image locally as follows: 74 | 75 | .. code-block:: bash 76 | 77 | docker build -t my-app . 78 | 79 | This will go through all the commands in your Dockerfile, and if successful, store an image with your local Docker server that you could then run: 80 | 81 | .. code-block:: bash 82 | 83 | docker run -e DATABASE_URL='' -t my-app 84 | 85 | This command is merely a smoke test to make sure uWSGI runs, and won't connect to a database or any other external services. 86 | 87 | 88 | Running Commands During Container Start-Up 89 | ------------------------------------------ 90 | 91 | As a final step, I recommend creating an ``ENTRYPOINT`` script to run commands as needed during container start-up. This will let us accomplish any number of things, such as making sure Postgres is available or running ``migrate`` during container start-up. Save the following to a file named ``docker-entrypoint.sh`` in the same directory as your ``Dockerfile``: 92 | 93 | .. code-block:: bash 94 | {% filter indent(width=4) %} 95 | {% include "docker-entrypoint.sh" %} 96 | {%- endfilter %} 97 | Make sure this file is executable, i.e.: 98 | 99 | .. code-block:: bash 100 | 101 | chmod a+x docker-entrypoint.sh 102 | 103 | Next, uncomment the following line to your ``Dockerfile``, just above the ``CMD`` statement: 104 | 105 | .. code-block:: docker 106 | 107 | ENTRYPOINT ["/code/docker-entrypoint.sh"] 108 | 109 | This will (a) make sure a database is available (usually only needed when used with Docker Compose) and (b) run outstanding migrations, if any, if the ``DJANGO_MANAGEPY_MIGRATE`` is set to ``on`` in your environment. Even if you add this entrypoint script as-is, you could still choose to run ``migrate`` or ``collectstatic`` in separate steps in your deployment before releasing the new container. The only reason you might not want to do this is if your application is highly sensitive to container start-up time, or if you want to avoid any database calls as the container starts up (e.g., for local testing). If you do rely on these commands being run during container start-up, be sure to set the relevant variables in your container's environment. 110 | 111 | 112 | Creating a Production-Like Environment Locally with Docker Compose 113 | ------------------------------------------------------------------ 114 | 115 | To run a complete copy of production services locally, you can use `Docker Compose `_. The following ``docker-compose.yml`` will create a barebones, ephemeral, AWS-like container environment with Postgres for testing your production environment locally. 116 | 117 | *This is intended for local testing of your production environment only, and will not save data from stateful services like Postgres upon container shutdown.* 118 | 119 | .. code-block:: yaml 120 | {% filter indent(width=4) %} 121 | {% include "docker-compose.yml" %} 122 | {%- endfilter %} 123 | Copy this into a file named ``docker-compose.yml`` in the same directory as your ``Dockerfile``, and then run: 124 | 125 | .. code-block:: bash 126 | 127 | docker-compose up --build -d 128 | 129 | This downloads (or builds) and starts the two containers listed above. You can view output from the containers by running: 130 | 131 | .. code-block:: bash 132 | 133 | docker-compose logs 134 | 135 | If all services launched successfully, you should now be able to access your application at http://localhost:8000/ in a web browser. 136 | 137 | If you need to debug your application container, a handy way to launch an instance it and poke around is: 138 | 139 | .. code-block:: bash 140 | 141 | docker-compose run app /bin/bash 142 | 143 | 144 | Static Files 145 | ------------ 146 | 147 | You may have noticed that we set up static file serving in uWSGI via the ``UWSGI_STATIC_MAP`` and ``UWSGI_STATIC_EXPIRES_URI`` environment variables. If preferred, you can turn this off and use `Django Whitenoise `_ or `copy your static files straight to S3 `_. 148 | 149 | 150 | Blocking ``Invalid HTTP_HOST header`` Errors with uWSGI 151 | ------------------------------------------------------- 152 | 153 | To avoid Django's ``Invalid HTTP_HOST header`` errors (and prevent any such spurious requests from taking up any more CPU cycles than absolutely necessary), you can also configure uWSGI to return an ``HTTP 400`` response immediately without ever invoking your application code. This can be accomplished by uncommenting and customizing the ``UWSGI_ROUTE_HOST`` line in the Dockerfile above. 154 | 155 | 156 | Summary 157 | ------- 158 | 159 | That concludes this high-level introduction to containerizing your Python/Django app for hosting on AWS Elastic Beanstalk (EB), Elastic Container Service (ECS), or elsewhere. Each application and Dockerfile will be slightly different, but I hope this provides a good starting point for your containers. Shameless plug: if you're looking for a simple (and at least temporarily free) way to test your Docker containers on AWS using an Elastic Beanstalk Multicontainer Docker environment or the Elastic Container Service, check out Caktus' very own `AWS Web Stacks `_. Good luck! 160 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | This is a DRAFT (possibly including untested, yet-to-be released) edits to the blog post published here: https://www.caktusgroup.com/blog/2017/03/14/production-ready-dockerfile-your-python-django-app/ 2 | 3 | **Have a suggested tweak or fix?** Feel free to submit a PR. 4 | 5 | 6 | A Production-ready Dockerfile for Your Python/Django App 7 | ======================================================== 8 | 9 | **Update (October 29, 2019):** I updated this post with more recent Django and Postgres versions, to use Python and pip directly in the container (instead of in a separate virtual environment, which was unnecessary), and switched to a non-root user via Docker instead of uWSGI. 10 | 11 | Docker has matured a lot since it was released. We've been watching it closely at Caktus, and have been thrilled by the adoption -- both by the community and by service providers. As a team of Python and Django developers, we're always searching for best of breed deployment tools. Docker is a clear fit for packaging the underlying code for many projects, including the Python and Django apps we build at Caktus. 12 | 13 | This post also includes an `accompanying GitHub repo `_. 14 | 15 | Technical Overview 16 | ------------------ 17 | 18 | There are many ways to containerize a Python/Django app, no one of which could be considered "the best." That being said, I think the following approach provides a good balance of simplicity, configurability, and container size. The specific tools I use are: `Docker `_ (of course), the `python:3.7-slim `_ Docker image (based on Debian Stretch), and `uWSGI `_. 19 | 20 | In a previous version of this post, I used `Alpine Linux `_ as the base image for this 21 | Dockerfile. But now, I'm switching to a Debian- and glibc-based image, because I found `an inconvenient workaround `_ was required for `musl libc `_'s ``strftime()`` implementation. The Debian-based "slim" images are still relatively small; the bare-minimum image described below increases from about 170 MB to 227 MB (~33%) when switching from ``python:3.7-alpine`` to ``python:3.7-slim`` (and updating all the corresponding system packages). 22 | 23 | There are many WSGI servers available for Python, and we use both Gunicorn and uWSGI at Caktus. A couple of the benefits of uWSGI are that (1) it's almost entirely configurable through environment variables (which fits well with containers), and (2) it includes `native HTTP support `_, which can circumvent the need for a separate HTTP server like Apache or Nginx. 24 | 25 | 26 | The Dockerfile 27 | -------------- 28 | 29 | Without further ado, here's a production-ready ``Dockerfile`` you can use as a starting point for your project (it should be added in your top level project directory, next to the ``manage.py`` script provided by your Django project): 30 | 31 | .. code-block:: docker 32 | 33 | FROM python:3.7-slim 34 | 35 | # Create a group and user to run our app 36 | ARG APP_USER=appuser 37 | RUN groupadd -r ${APP_USER} && useradd --no-log-init -r -g ${APP_USER} ${APP_USER} 38 | 39 | # Install packages needed to run your application (not build deps): 40 | # mime-support -- for mime types when serving static files 41 | # postgresql-client -- for running database commands 42 | # We need to recreate the /usr/share/man/man{1..8} directories first because 43 | # they were clobbered by a parent image. 44 | RUN set -ex \ 45 | && RUN_DEPS=" \ 46 | libpcre3 \ 47 | mime-support \ 48 | postgresql-client \ 49 | " \ 50 | && seq 1 8 | xargs -I{} mkdir -p /usr/share/man/man{} \ 51 | && apt-get update && apt-get install -y --no-install-recommends $RUN_DEPS \ 52 | && rm -rf /var/lib/apt/lists/* 53 | 54 | # Copy in your requirements file 55 | ADD requirements.txt /requirements.txt 56 | 57 | # OR, if you're using a directory for your requirements, copy everything (comment out the above and uncomment this if so): 58 | # ADD requirements /requirements 59 | 60 | # Install build deps, then run `pip install`, then remove unneeded build deps all in a single step. 61 | # Correct the path to your production requirements file, if needed. 62 | RUN set -ex \ 63 | && BUILD_DEPS=" \ 64 | build-essential \ 65 | libpcre3-dev \ 66 | libpq-dev \ 67 | " \ 68 | && apt-get update && apt-get install -y --no-install-recommends $BUILD_DEPS \ 69 | && pip install --no-cache-dir -r /requirements.txt \ 70 | \ 71 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $BUILD_DEPS \ 72 | && rm -rf /var/lib/apt/lists/* 73 | 74 | # Copy your application code to the container (make sure you create a .dockerignore file if any large files or directories should be excluded) 75 | RUN mkdir /code/ 76 | WORKDIR /code/ 77 | ADD . /code/ 78 | 79 | # uWSGI will listen on this port 80 | EXPOSE 8000 81 | 82 | # Add any static environment variables needed by Django or your settings file here: 83 | ENV DJANGO_SETTINGS_MODULE=my_project.settings.deploy 84 | 85 | # Call collectstatic (customize the following line with the minimal environment variables needed for manage.py to run): 86 | RUN DATABASE_URL='' python manage.py collectstatic --noinput 87 | 88 | # Tell uWSGI where to find your wsgi file (change this): 89 | ENV UWSGI_WSGI_FILE=my_project/wsgi.py 90 | 91 | # Base uWSGI configuration (you shouldn't need to change these): 92 | ENV UWSGI_HTTP=:8000 UWSGI_MASTER=1 UWSGI_HTTP_AUTO_CHUNKED=1 UWSGI_HTTP_KEEPALIVE=1 UWSGI_LAZY_APPS=1 UWSGI_WSGI_ENV_BEHAVIOR=holy 93 | 94 | # Number of uWSGI workers and threads per worker (customize as needed): 95 | ENV UWSGI_WORKERS=2 UWSGI_THREADS=4 96 | 97 | # uWSGI static file serving configuration (customize or comment out if not needed): 98 | ENV UWSGI_STATIC_MAP="/static/=/code/static/" UWSGI_STATIC_EXPIRES_URI="/static/.*\.[a-f0-9]{12,}\.(css|js|png|jpg|jpeg|gif|ico|woff|ttf|otf|svg|scss|map|txt) 315360000" 99 | 100 | # Deny invalid hosts before they get to Django (uncomment and change to your hostname(s)): 101 | # ENV UWSGI_ROUTE_HOST="^(?!localhost:8000$) break:400" 102 | 103 | # Change to a non-root user 104 | USER ${APP_USER}:${APP_USER} 105 | 106 | # Uncomment after creating your docker-entrypoint.sh 107 | # ENTRYPOINT ["/code/docker-entrypoint.sh"] 108 | 109 | # Start uWSGI 110 | CMD ["uwsgi", "--show-config"] 111 | 112 | We extend from the "slim" flavor of the official Docker image for Python 3.7, install a few dependencies for running our application (i.e., that we want to keep in the final version of the image), copy the folder containing our requirements files to the container, and then, in a single line, (a) install the build dependencies needed, (b) ``pip install`` the requirements themselves (edit this line to match the location of your requirements file, if needed), (c) remove the C compiler and any other OS packages no longer needed, and (d) remove the package lists since they're no longer needed. It's important to keep this all on one line so that Docker will cache the entire operation as a single layer. 113 | 114 | Next, we copy our application code to the image, set some default environment variables, and run ``collectstatic``. Be sure to change the values for ``DJANGO_SETTINGS_MODULE`` and ``UWSGI_WSGI_FILE`` to the correct paths for your application (note that the former requires a Python package path, while the latter requires a file system path). 115 | 116 | A few notes about other aspects of this Dockerfile: 117 | 118 | * I only included a minimal set of OS dependencies here. If this is an established production app, you'll most likely need to visit https://packages.debian.org, search for the Debian package names of the OS dependencies you need, including the ``-dev`` supplemental packages as needed, and add them either to ``RUN_DEPS`` or ``BUILD_DEPS`` in your Dockerfile. 119 | * Adding ``--no-cache-dir`` to the ``pip install`` command saves a additional disk space, as this prevents ``pip`` from `caching downloads `_ and `caching wheels `_ locally. Since you won't need to install requirements again after the Docker image has been created, this can be added to the ``pip install`` command. Thanks Hemanth Kumar for this tip! 120 | * uWSGI contains a lot of optimizations for running many apps from the same uWSGI process. These optimizations aren't really needed when running a single app in a Docker container, and can `cause issues `_ when used with certain 3rd-party packages. I've added ``UWSGI_LAZY_APPS=1`` and ``UWSGI_WSGI_ENV_BEHAVIOR=holy`` to the uWSGI configuration to provide a more stable uWSGI experience (the latter will be the default in the next uWSGI release). 121 | * The ``UWSGI_HTTP_AUTO_CHUNKED`` and ``UWSGI_HTTP_KEEPALIVE`` options to uWSGI are needed in the event the container will be hosted behind an Amazon Elastic Load Balancer (ELB), because Django doesn't set a valid ``Content-Length`` header by default, unless the ``ConditionalGetMiddleware`` is enabled. See `the note `_ at the end of the uWSGI documentation on HTTP support for further detail. 122 | 123 | 124 | Requirements and Settings Files 125 | ------------------------------- 126 | 127 | Production-ready requirements and settings files are outside the scope of this post, but you'll need to include a few things in your requirements file(s), if they're not there already:: 128 | 129 | Django>=2.2,<2.3 130 | uwsgi>=2.0,<2.1 131 | dj-database-url>=0.5,<0.6 132 | psycopg2>=2.8,<2.9 133 | 134 | I didn't pin these to specific versions here to help future-proof this post somewhat, but you'll likely want to pin these (and other) requirements to specific versions so things don't suddenly start breaking in production. Of course, you don't have to use any of these packages, but you'll need to adjust the corresponding code elsewhere in this post if you don't. 135 | 136 | My ``deploy.py`` settings file looks like this: 137 | 138 | .. code-block:: python 139 | 140 | import os 141 | 142 | import dj_database_url 143 | 144 | from . import * # noqa: F403 145 | 146 | # This is NOT a complete production settings file. For more, see: 147 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ 148 | 149 | DEBUG = False 150 | 151 | ALLOWED_HOSTS = ['localhost'] 152 | 153 | DATABASES['default'] = dj_database_url.config(conn_max_age=600) # noqa: F405 154 | 155 | STATIC_ROOT = os.path.join(BASE_DIR, 'static') # noqa: F405 156 | 157 | STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' 158 | 159 | This bears repeating: This is **not** a production-ready settings file, and you should review `the checklist `_ in the Django docs (and run ``python manage.py check --deploy --settings=my_project.settings.deploy``) to ensure you've properly secured your production settings file. 160 | 161 | 162 | Building and Testing the Container 163 | ---------------------------------- 164 | 165 | Now that you have the essentials in place, you can build your Docker image locally as follows: 166 | 167 | .. code-block:: bash 168 | 169 | docker build -t my-app . 170 | 171 | This will go through all the commands in your Dockerfile, and if successful, store an image with your local Docker server that you could then run: 172 | 173 | .. code-block:: bash 174 | 175 | docker run -e DATABASE_URL='' -t my-app 176 | 177 | This command is merely a smoke test to make sure uWSGI runs, and won't connect to a database or any other external services. 178 | 179 | 180 | Running Commands During Container Start-Up 181 | ------------------------------------------ 182 | 183 | As a final step, I recommend creating an ``ENTRYPOINT`` script to run commands as needed during container start-up. This will let us accomplish any number of things, such as making sure Postgres is available or running ``migrate`` during container start-up. Save the following to a file named ``docker-entrypoint.sh`` in the same directory as your ``Dockerfile``: 184 | 185 | .. code-block:: bash 186 | 187 | #!/bin/sh 188 | set -e 189 | 190 | until psql $DATABASE_URL -c '\l'; do 191 | >&2 echo "Postgres is unavailable - sleeping" 192 | sleep 1 193 | done 194 | 195 | >&2 echo "Postgres is up - continuing" 196 | 197 | if [ "x$DJANGO_MANAGEPY_MIGRATE" = 'xon' ]; then 198 | python manage.py migrate --noinput 199 | fi 200 | 201 | exec "$@" 202 | 203 | Make sure this file is executable, i.e.: 204 | 205 | .. code-block:: bash 206 | 207 | chmod a+x docker-entrypoint.sh 208 | 209 | Next, uncomment the following line to your ``Dockerfile``, just above the ``CMD`` statement: 210 | 211 | .. code-block:: docker 212 | 213 | ENTRYPOINT ["/code/docker-entrypoint.sh"] 214 | 215 | This will (a) make sure a database is available (usually only needed when used with Docker Compose) and (b) run outstanding migrations, if any, if the ``DJANGO_MANAGEPY_MIGRATE`` is set to ``on`` in your environment. Even if you add this entrypoint script as-is, you could still choose to run ``migrate`` or ``collectstatic`` in separate steps in your deployment before releasing the new container. The only reason you might not want to do this is if your application is highly sensitive to container start-up time, or if you want to avoid any database calls as the container starts up (e.g., for local testing). If you do rely on these commands being run during container start-up, be sure to set the relevant variables in your container's environment. 216 | 217 | 218 | Creating a Production-Like Environment Locally with Docker Compose 219 | ------------------------------------------------------------------ 220 | 221 | To run a complete copy of production services locally, you can use `Docker Compose `_. The following ``docker-compose.yml`` will create a barebones, ephemeral, AWS-like container environment with Postgres for testing your production environment locally. 222 | 223 | *This is intended for local testing of your production environment only, and will not save data from stateful services like Postgres upon container shutdown.* 224 | 225 | .. code-block:: yaml 226 | 227 | version: "2" 228 | 229 | services: 230 | db: 231 | environment: 232 | POSTGRES_DB: app_db 233 | POSTGRES_USER: app_user 234 | POSTGRES_PASSWORD: changeme 235 | restart: always 236 | image: postgres:12 237 | expose: 238 | - "5432" 239 | app: 240 | environment: 241 | DATABASE_URL: postgres://app_user:changeme@db/app_db 242 | DJANGO_MANAGEPY_MIGRATE: "on" 243 | build: 244 | context: . 245 | dockerfile: ./Dockerfile 246 | links: 247 | - db:db 248 | ports: 249 | - "8000:8000" 250 | 251 | Copy this into a file named ``docker-compose.yml`` in the same directory as your ``Dockerfile``, and then run: 252 | 253 | .. code-block:: bash 254 | 255 | docker-compose up --build -d 256 | 257 | This downloads (or builds) and starts the two containers listed above. You can view output from the containers by running: 258 | 259 | .. code-block:: bash 260 | 261 | docker-compose logs 262 | 263 | If all services launched successfully, you should now be able to access your application at http://localhost:8000/ in a web browser. 264 | 265 | If you need to debug your application container, a handy way to launch an instance it and poke around is: 266 | 267 | .. code-block:: bash 268 | 269 | docker-compose run app /bin/bash 270 | 271 | 272 | Static Files 273 | ------------ 274 | 275 | You may have noticed that we set up static file serving in uWSGI via the ``UWSGI_STATIC_MAP`` and ``UWSGI_STATIC_EXPIRES_URI`` environment variables. If preferred, you can turn this off and use `Django Whitenoise `_ or `copy your static files straight to S3 `_. 276 | 277 | 278 | Blocking ``Invalid HTTP_HOST header`` Errors with uWSGI 279 | ------------------------------------------------------- 280 | 281 | To avoid Django's ``Invalid HTTP_HOST header`` errors (and prevent any such spurious requests from taking up any more CPU cycles than absolutely necessary), you can also configure uWSGI to return an ``HTTP 400`` response immediately without ever invoking your application code. This can be accomplished by uncommenting and customizing the ``UWSGI_ROUTE_HOST`` line in the Dockerfile above. 282 | 283 | 284 | Summary 285 | ------- 286 | 287 | That concludes this high-level introduction to containerizing your Python/Django app for hosting on AWS Elastic Beanstalk (EB), Elastic Container Service (ECS), or elsewhere. Each application and Dockerfile will be slightly different, but I hope this provides a good starting point for your containers. Shameless plug: if you're looking for a simple (and at least temporarily free) way to test your Docker containers on AWS using an Elastic Beanstalk Multicontainer Docker environment or the Elastic Container Service, check out Caktus' very own `AWS Web Stacks `_. Good luck! 288 | --------------------------------------------------------------------------------