Deploying Django to production with uWSGI

There are many posts about dockerizing Django apps but I feel like there's some room for improvement.

Many of the approaches only focused on development flow, making the resulting image great for iterating but not ideal for production usage. Other ones, produced very big images, were running the application as root or ignored handling static files and defered it to S3. With this images I had several goals:

  • Production-ready image (following general security tips)
  • Using alpine base for a small image
  • Multi-stage build for even smaller and cleaner image
  • Handling static files inside the same container

I decided on using uWSGI which actually also can serve static files, making my life much easier.

Note: I'm using pipenv to manage virtualenv and all dependencies (and I recommend you use it too), therefore some instructions might need tweaking if you're using bare pip.

Instructions

Setup uWSGI

Start by adding it to your dependencies:

$ pipenv install uwsgi

Then create uwsgi.ini file in your project root directory:

[uwsgi]
chdir = /app
uid = $(UID)
gid = $(GID)
module = $(UWSGI_MODULE)
processes = $(UWSGI_PROCESSES)
threads = $(UWSGI_THREADS)
procname-prefix-spaced = uwsgi:$(UWSGI_MODULE)

http-socket = :8080
http-enable-proxy-protocol = 1
http-auto-chunked = true
http-keepalive = 75
http-timeout = 75
stats = :1717
stats-http = 1
offload-threads = $(UWSGI_OFFLOAD_THREADS)

# Better startup/shutdown in docker:
die-on-term = 1
lazy-apps = 0

vacuum = 1
master = 1
enable-threads = true
thunder-lock = 1
buffer-size = 65535

# Logging
log-x-forwarded-for = true

# Avoid errors on aborted client connections
ignore-sigpipe = true
ignore-write-errors = true
disable-write-exception = true

no-defer-accept = 1

# Limits, Kill requests after 120 seconds
harakiri = 120
harakiri-verbose = true
post-buffering = 4096

# Custom headers
add-header = X-Content-Type-Options: nosniff
add-header = X-XSS-Protection: 1; mode=block
add-header = Strict-Transport-Security: max-age=16070400
add-header = Connection: Keep-Alive

# Static file serving with caching headers and gzip
static-map = /static=/app/staticfiles
static-map = /media=/app/media
static-safe = /usr/local/lib/python3.7/site-packages/
static-gzip-dir = /app/staticfiles/
static-expires = /app/staticfiles/CACHE/* $(UWSGI_STATIC_EXPIRES)
static-expires = /app/media/cache/* $(UWSGI_STATIC_EXPIRES)
static-expires = /app/staticfiles/frontend/img/* $(UWSGI_STATIC_EXPIRES)
static-expires = /app/staticfiles/frontend/fonts/* $(UWSGI_STATIC_EXPIRES)
static-expires = /app/* 3600
route-uri = ^/static/ addheader:Vary: Accept-Encoding
error-route-uri = ^/static/ addheader:Cache-Control: no-cache

# Cache stat() calls
cache2 = name=statcalls,items=30
static-cache-paths = 86400

# Redirect http -> https
route-if = equal:${HTTP_X_FORWARDED_PROTO};http redirect-permanent:https://${HTTP_HOST}${REQUEST_URI}

Note: The author of the config above is Diederik van der Boor 🙇

Create the Dockerfile

# Build argument allowing to change Python version
ARG PYTHON_VERSION=3.7

# Build dependencies in separate container
FROM python:${PYTHON_VERSION}-alpine AS builder
ENV WORKDIR /app
COPY Pipfile ${WORKDIR}/
COPY Pipfile.lock ${WORKDIR}/

RUN cd ${WORKDIR} \
    && pip install pipenv \
    && pipenv install --system

# Create the final container with the app
FROM python:${PYTHON_VERSION}-alpine

ENV USER=docker \
    GROUP=docker \
    UID=12345 \
    GID=23456 \
    HOME=/app \
    PYTHONUNBUFFERED=1
WORKDIR ${HOME}

# Create user/group
RUN addgroup --gid "${GID}" "${GROUP}" \
    && adduser \
    --disabled-password \
    --gecos "" \
    --home "$(pwd)" \
    --ingroup "${GROUP}" \
    --no-create-home \
    --uid "${UID}" \
    "${USER}"

# Run as docker user
USER ${USER}
# Copy installed packages
COPY --from=builder /usr/local/lib/python3.7/site-packages /usr/local/lib/python3.7/site-packages
# Copy uWSGI binary
COPY --from=builder /usr/local/bin/uwsgi /usr/local/bin/uwsgi
# Copy the application
COPY --chown=docker:docker . .
# Collect static files
RUN python manage.py collectstatic --noinput

ENTRYPOINT [ "uwsgi", "--ini", "uwsgi.ini" ]
EXPOSE 8080

Starting the container

To work correctly, you need to set some environment variables. If you're using Docker Compose, you could use similar docker-compose.yml file:

version: "3"
services:
  app:
    build: .
    image: foo/bar
    ports:
      - "8080:8080"
    environment:
      - UWSGI_MODULE=myapp.wsgi:application
      - UWSGI_PROCESSES=10
      - UWSGI_THREADS=2
      - UWSGI_OFFLOAD_THREADS=10
      - UWSGI_STATIC_EXPIRES=86400

then you can build the image with following command:

$ docker-compose build app

Conclusion

I'm quite happy with this setup and it has been working in production for some time. Please let me know if you have any comments and if I could improve this more!