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
└── ...
Stack Diagram
To visualize the flow better here's a diagram that describes how everything is interconnected:
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"]
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
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
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
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
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
orlatest
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
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:
-
docker compose down
: Spin the old instance down -
docker system prune -a -f
: This makes sure I remove thelatest
image to force the download of the new one from the registry. -
docker compose up --scale worker=5 -d
: Spin the new instance up That's it!
Top comments (3)
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.
Nice one! Will investigate more, thanks for sharing!
Could you share project github link ?