DEV Community

Cover image for My Docker stack to deploy a Django + Celery web app
Daniel
Daniel

Posted on

2

My Docker stack to deploy a Django + Celery web app


After many iterations, this is my current process to deploy a django application.

The cool thing is that I'm now deploying with only an .env file and nothing else.

Note that this is just a single instance of the stack, without Kubernetes or any kind of load balancer.

Introduction

My stack consists on:

  • A Postgres database
  • A Redis database
  • A django instance
  • Celery beat and workers

I won't go into details of how to set up anything like this here, if you want to learn more about celery in their docs.


Project Structure

To understand the docker-compose.yml file below, it's important to see how I structure my django project:



MY-DJANGO-PROJECT/
├── core  # settings.py lives here
├── app1/
│   ├── migrations
│   ├── models.py
│   └── ...
├── app2/
│   ├── migrations
│   ├── models.py
│   └── ...
├── data  # A data directory where I store stuff like logs
├── nginx/
│   ├── certs/
│   │   ├── fullchain.pem
│   │   └── privkey.pem
│   ├── conf/
│   │   ├── default.conf
│   │   ├── prod.conf
│   │   └── staging.conf
│   └── Dockerfile
├── Dockerfile
├── entrypoint-django.sh
├── entrypoint-beat.sh
├── entrypoint-worker.sh
├── Pipfile
└── ...


Enter fullscreen mode Exit fullscreen mode

Stack Diagram

To visualize the flow better here's a diagram that describes how everything is interconnected:

Docker Stack Diagram


Docker images

The stack, though it might seem complicated, is only composed of 3 images, 2 of which are custom:

Django image

This is a custom image built from python.

This image will be used for django, celery workers and celery beat containers.

Here's the Dockerfile for it:



# Python as the base image
# I use bullseye because I'm more comfortable with it
# but you can use Alpine for a more lightweight container
FROM python:3.11-bullseye

# Exposes port 8000
# Make sure to change this to your used port
EXPOSE 8000

# Keeps Python from generating .pyc files in the container
ENV PYTHONDONTWRITEBYTECODE=1

# Turns off buffering for easier container logging
ENV PYTHONUNBUFFERED=1

# Working directory
WORKDIR /app/backend

# Install pipenv
# This is not necessary if you use pip in your code
RUN pip install -U pipenv

# Install pipenv requirements
# Turns the Pipfile to a requirements.txt
# so it can be installed globally with pip
COPY Pipfile Pipfile.lock /app/backend/
RUN pipenv requirements > requirements.txt
RUN pip install -r requirements.txt
RUN rm -rf ./Pipfile ./Pipfile.lock

# Copy all the code over
COPY . .

# Create the media directory
RUN mkdir -p /app/backend/media

# Create a volume for the media directory
VOLUME /app/backend/media

# Create a volume for the static directory
VOLUME /app/backend/django_static

# Make the entrypoint scripts executable
# There's one entrypoint for each service that uses this image
RUN chmod +x /app/backend/entrypoint-django.sh
RUN chmod +x /app/backend/entrypoint-worker.sh
RUN chmod +x /app/backend/entrypoint-beat.sh

# Set the default entrypoint in case this Dockerfile is run
# by itself
ENTRYPOINT ["/app/backend/entrypoint-django.sh"]


Enter fullscreen mode Exit fullscreen mode

These are the entry point files for each service:

django entry point



#!/bin/bash

# Migrate any new migrations to the database on deployment
echo "Migrating..."

python manage.py migrate --no-input

# Collect static files
echo "Collecting static files..."

python manage.py collectstatic --no-input

# Ensure the data directory exists
# I use the data directory to store files such as logs
mkdir -p data

# Start gunicorn
echo "Starting server..."

gunicorn core.wsgi:application --forwarded-allow-ips="*" --bind 0.0.0.0:8000


Enter fullscreen mode Exit fullscreen mode

Worker entry point



#!/bin/sh

# Wait until the backend directory is created
until cd /app/backend
do
    echo "Waiting for server volume..."
done

# run a worker
# I like having only one task per worker but you can change it
# by increasing the concurrency
echo "Starting celery worker..."
celery -A core worker -l info --concurrency 1 -E


Enter fullscreen mode Exit fullscreen mode

Beat entry point



#!/bin/sh

# Wait until the server volume is available
until cd /app/backend
do
    echo "Waiting for server volume..."
done

# run celery beat
echo "Starting celery beat..."
celery -A core beat -l info


Enter fullscreen mode Exit fullscreen mode

Nginx image

This container serves the application.

I create a custom nginx image that includes my certificates and configuration, so I don't have to copy them over to the server.

Note: I don't use certbot, as I find it more straightfoward to generate the certificates from cloudflare and just store them in the custom image

This means that the image should be secure in a private registry with authentication, otherwise you risk security of your web app.

Here's the Dockerfile for it:



FROM nginx:stable-bullseye

# Export ports 80 and 443
EXPOSE 80
EXPOSE 443

# Copy the nginx configuration files to the image
COPY ./conf/default.conf /etc/nginx/conf.d/default.conf
COPY ./conf/prod.conf /etc/nginx/conf.d/prod.conf
COPY ./conf/staging.conf /etc/nginx/conf.d/staging.conf

# Copy the CloudFlare Origin CA certificate to the image
COPY ./certs/fullchain.pem /etc/nginx/certs/fullchain.pem
COPY ./certs/privkey.pem /etc/nginx/certs/privkey.pem


Enter fullscreen mode Exit fullscreen mode

Redis image

I just use the default Redis image for this.

Just want to note that, because this is a single instance deployment, I like deploying Redis directly here as I find it's enough.

It is recommended, though, to spin up a Redis database somewhere more centralized.


Docker Compose

Environment Variables

Before I get into the gist of the Docker Compose file here are some environment variables I put in my .env file for deployment:

  • DOCKER_REGISTRY: My private, authentication enabled, docker registry where I upload the build images
  • DJANGO_DOCKER_IMAGE: The name I give the django image
  • NGINX_DOCKER_IMAGE: The name I give the NGINX image
  • DOCKER_TAG: Usually the version I want to deploy, i.e.: 1.5 or latest

The file



version: "3"

services:
  redis:
    container_name: redis
    restart: unless-stopped
    image: redis:7.2.0-alpine
    expose:
      - 6379

  backend:
    restart: unless-stopped
    image: ${DOCKER_REGISTRY}/${DJANGO_DOCKER_IMAGE}:${DOCKER_TAG}
    env_file:
      - ./.env
    entrypoint: /app/backend/entrypoint-django.sh
    ports:
      - 8000:8000
    volumes:
      - ./data:/app/backend/data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/healthcheck/"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s 

  worker:
    restart: unless-stopped
    image: ${DOCKER_REGISTRY}/${DJANGO_DOCKER_IMAGE}:${DOCKER_TAG}
    env_file:
      - ./.env
    entrypoint: /app/backend/entrypoint-worker.sh
    volumes:
      - ./data:/app/backend/data
    depends_on:
      backend
        condition: service_healthy
      redis
        condition: service_started

  beat:
    restart: unless-stopped
    image: ${DOCKER_REGISTRY}/${DJANGO_DOCKER_IMAGE}:${DOCKER_TAG}
    env_file:
      - ./.env
    entrypoint: /app/backend/entrypoint-beat.sh
    volumes:
      - ./data:/app/backend/data
    depends_on:
      backend
        condition: service_healthy
      redis
        condition: service_started

  nginx:
    restart: unless-stopped
    image: ${DOCKER_REGISTRY}/${NGINX_DOCKER_IMAGE}:${DOCKER_TAG}
    ports:
      - 80:80
      - 443:443
    depends_on:
      backend
        condition: service_healthy


Enter fullscreen mode Exit fullscreen mode

As you can see, the compose file has the 5 services, Redis, django, celery worker, celery beat and NGINX.


Deploying

Building

First I build the images and push them to the registry. Before, I did this manually, now I use a GitHub action. You can learn more about this automation here.

Deploying with Docker Compose

Then I head to the server where I want to deploy this. Make sure that the .env file is updated and then just:

  1. docker compose down: Spin the old instance down
  2. docker system prune -a -f: This makes sure I remove the latest image to force the download of the new one from the registry.
  3. docker compose up --scale worker=5 -d: Spin the new instance up That's it!

Speedy emails, satisfied customers

Postmark Image

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up

Top comments (3)

Collapse
 
lyralemos profile image
Alexandre Marinho

Nice setup! You could make it better by adding a containrrr/watchtower image. That way when you upload a new image to your registry watchtower will automatically download the new image a recreate your containers.

Collapse
 
onticdani profile image
Daniel

Nice one! Will investigate more, thanks for sharing!

Collapse
 
davronbekdev profile image
DavronbekDev

Could you share project github link ?

Image of AssemblyAI

Automatic Speech Recognition with AssemblyAI

Experience near-human accuracy, low-latency performance, and advanced Speech AI capabilities with AssemblyAI's Speech-to-Text API. Sign up today and get $50 in API credit. No credit card required.

Try the API

👋 Kindness is contagious

Explore a sea of insights with this enlightening post, highly esteemed within the nurturing DEV Community. Coders of all stripes are invited to participate and contribute to our shared knowledge.

Expressing gratitude with a simple "thank you" can make a big impact. Leave your thanks in the comments!

On DEV, exchanging ideas smooths our way and strengthens our community bonds. Found this useful? A quick note of thanks to the author can mean a lot.

Okay