Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 75

Containerizing Django with Docker Compose

Recommended resources

Hello! Welcome to the next module in our Backend Engineering course: Containerization and Application Deployment.

In the previous modules, you've built a robust Django API, complete with database models, authentication, and even asynchronous tasks. Most recently, we explored how TLS secures your application's data as it travels across the network. Now, we face a new challenge: how do we package this entire application—the Django code, the Python environment, the database, and the message broker—so that it can be run reliably and consistently anywhere?

This lesson addresses that question head-on. Our learning outcome is to containerize a Django application and its services (e.g., PostgreSQL, Redis) using Docker and Docker Compose. We will tackle the infamous "it works on my machine" problem by bundling our application and its dependencies into isolated, portable environments called containers.

This is a foundational skill for modern backend development and your first major step toward deploying applications to the cloud.

The Multi-Service Architecture

Our Django application isn't a monolith; it relies on other services to function. We have the web application itself, a PostgreSQL database for data storage, and a Redis instance that acts as a message broker for Celery.

The diagram below illustrates this kind of multi-service architecture.

Django Application Architecture with Containerized Services
This diagram shows how different services—Django, PostgreSQL, Redis, and Celery—interact within a containerized environment, with Nginx acting as a gateway.

Our goal today is to take this entire collection of services and package it so it can be started, stopped, and managed as a single unit. We'll use two key tools for this: Docker to package each individual service, and Docker Compose to define and run the multi-service application.


Why Containerize? The Core Concepts

Before we write any code, it's crucial to understand the fundamental ideas behind containerization. Docker provides a way to run applications in isolated environments, ensuring consistency from development to production.

To get a solid grasp of these concepts, please review the introductory slides from this workshop on containerization.

Containerizing Python Web Apps

This presentation provides a clear, high-level overview of Docker's core components and the motivation for using them.

Please review the first section, 'Docker', which runs from the 'Docker overview' slide to the 'Image layers' slide. Focus on the distinction between an image (a blueprint) and a container (a running instance of that blueprint).

As you saw, Docker solves several key problems:

  • Consistency: It bundles the application, its dependencies, and its configuration into a single package (an image), eliminating environment-specific bugs.
  • Isolation: A container runs in an isolated environment on the host OS, preventing conflicts between different applications or dependencies.
  • Portability: A Docker image built on your laptop will run identically on a colleague's machine, a testing server, or in the cloud.

Your experience with complex AI pipelines, which often involve multiple stages and dependencies, provides a useful parallel. Imagine packaging your data preprocessing scripts, a model training environment with specific library versions, and a separate inference server into their own containers. This ensures each part of the pipeline runs exactly as intended, regardless of the underlying machine.


Part 1: Defining a Single Container with Dockerfile

The first step is to create a blueprint for our Django application container. This is done using a special file named Dockerfile. This file contains a series of instructions that Docker follows to build our image.

The following video provides a step-by-step guide to creating a Dockerfile for a Django project and then expands to using Docker Compose to orchestrate it with a PostgreSQL database. It's a comprehensive and modern walkthrough.

Docker - Django and PostgreSQL setup (with uv) from scratch!

This video will be our primary guide for this lesson. We'll start by focusing on how to create a Dockerfile to containerize the Django application itself.

Watch the section from 01:44 to 10:35. This part covers the creation of the Dockerfile from scratch. Pay close attention to the purpose of each instruction (FROM, ENV, WORKDIR, COPY, RUN, CMD).

Let's break down the key instructions you saw in the video:

  • FROM python:3.13-slim-bookworm: Every Dockerfile starts with a FROM instruction. It specifies the base image to build upon. Here, we're using an official, lightweight Python image.
  • ENV PYTHONUNBUFFERED=1: Sets an environment variable inside the container. This specific one ensures that logs from our Python application are sent straight to the container's log stream without being buffered, which is essential for debugging.
  • WORKDIR /app: Sets the working directory for subsequent instructions. It's like running cd /app inside the container.
  • COPY requirements.txt .: Copies the requirements.txt file from our local machine into the container's working directory (/app).
  • RUN uv pip install -r requirements.txt --system: Executes a command inside the container. Here, we install our Python dependencies. The video uses uv, a very fast, modern package installer.
  • COPY ./source /app: Copies the rest of our Django source code into the container.
  • CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]: Specifies the default command to run when a container is started from this image. Binding to 0.0.0.0 is crucial, as it tells Django to listen for connections on all network interfaces inside the container, not just localhost.
Test your understanding!

In the video and the summary above, the requirements.txt file is copied and its packages are installed before the rest of the application source code is copied. Why is this done? What advantage does this specific ordering provide?

Show answer

This is a best practice for leveraging Docker's layer caching. Docker builds images in a series of layers, and it caches the result of each instruction. If a file hasn't changed, Docker will reuse the cached layer from a previous build instead of re-running the instruction.

Your project's dependencies (requirements.txt) change much less frequently than your source code. By installing them in a separate, earlier step, the layer containing the installed packages can be cached. When you then change your Python code and rebuild the image, Docker reuses the cached dependency layer and only re-runs the final COPY instruction for your source code. This makes subsequent builds much faster.


Part 2: Orchestrating Services with docker-compose.yml

Now that we have a Dockerfile for our Django app, we need a way to define and run it alongside our other services: PostgreSQL and Redis. This is where Docker Compose comes in. It uses a YAML file, typically docker-compose.yml, to configure the application's services.

Let's continue with the same video, which now builds the docker-compose.yml file.

Docker - Django and PostgreSQL setup (with uv) from scratch!

We will now see how to define our multi-service application. The video will first set up the web (Django) and db (PostgreSQL) services.

Watch the segment from 10:35 to 22:40. This covers the creation of the docker-compose.yml file, defining the web and db services, and configuring them to work together. Focus on build vs. image, volumes, ports, env_file, and depends_on.

This section was packed with important concepts. Here’s a summary of a docker-compose.yml for Django and PostgreSQL:

version: '3.8'

services:
  web:
    build: .
    container_name: django_app
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - ./source:/app # Mount local code for development
    ports:
      - "8000:8000" # Map host port 8000 to container port 8000
    env_file:
      - ./.env # Pass environment variables from a file
    depends_on:
      db:
        condition: service_healthy # Wait for the DB to be ready

  db:
    image: postgres:16-alpine # Use a pre-built image from Docker Hub
    container_name: postgres_db
    volumes:
      - postgres_data:/var/lib/postgresql/data/ # Persist DB data
    environment:
      - POSTGRES_DB=${POSTGRES_DB}
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data: # Define the named volume

Key concepts from this file:

  • services: Each top-level key (web, db) defines a service. Each service will run in its own container.
  • build vs. image: For our web service, we use build: . to tell Compose to build an image from the Dockerfile in the current directory. For db, we use image: postgres:16-alpine to pull a pre-built image directly from the Docker Hub registry.
  • volumes: This is crucial for development and data persistence.
    • ./source:/app: This is a bind mount. It maps the local source directory into the container's /app directory. Any changes you make to your code locally are immediately reflected inside the container, enabling a live-development workflow.
    • postgres_data:/var/lib/postgresql/data/: This is a named volume. Docker manages this volume, and it's used to persist the database data even if the db container is stopped and removed. This prevents you from losing your data every time you restart.
  • env_file / environment: This is how we pass configuration, especially secrets like database passwords, to our containers. Reading them from an .env file keeps them out of our version-controlled code.
  • depends_on & healthcheck: This controls startup order. We tell the web service to wait until the db service is not just running, but healthy (i.e., ready to accept connections).

Adding Redis to the Mix

Adding another service like Redis is straightforward. We simply add another service definition to our docker-compose.yml. The following video shows how to add both Redis and a Celery worker. For this lesson, we will focus only on adding the Redis service itself.

Docker - Django, Celery & Redis Docker Compose setup

Now let's see how easy it is to add Redis to our composition. This video demonstrates a setup including Celery, but we'll focus on the Redis service definition.

Watch the portion from 09:55 to 11:27. This specifically covers the db (PostgreSQL) and redis service definitions. Observe how the Redis service is defined using just an image from Docker Hub.

Adding Redis is as simple as:

# In your docker-compose.yml services section
  redis:
    image: redis:7-alpine
    container_name: redis_cache

Part 3: The Complete Workflow

You now have all the pieces. Let's outline the full workflow for running your containerized Django application.

  1. Create your Dockerfile to define the Django web container.
  2. Create your docker-compose.yml to define the web, db, and redis services and how they connect.
  3. Create an .env file in your project root to store your database credentials and other secrets.
    # .env
    POSTGRES_DB=app_db
    POSTGRES_USER=app_user
    POSTGRES_PASSWORD=supersecretpassword
    
  4. Configure settings.py to read database and Redis settings from environment variables, using os.environ.get().
  5. Build and run everything with a single command: docker compose up --build. The --build flag tells Compose to rebuild any images for which the Dockerfile or its context has changed.
  6. Run database migrations in a separate terminal while the services are running: docker compose exec web python manage.py migrate. The exec command allows you to run a command inside a specific, running service container.

The first video we watched demonstrates this final interaction, including running migrations and connecting to the database shell.

Docker - Django and PostgreSQL setup (with uv) from scratch!

Finally, let's see how to interact with our running containers to perform tasks like migrating the database.

Watch from 23:47 to 27:56. This section shows how to use docker exec (or docker compose exec) to run Django management commands (makemigrations, migrate, shell) inside the web container and how to connect to the psql shell inside the db container.

As a final practical tip, many developers use a shell script named entrypoint.sh to automate startup tasks like running migrations. This script is then executed by the CMD or ENTRYPOINT instruction in the Dockerfile. This ensures that whenever the web container starts, it automatically applies any pending database migrations before starting the web server.


Conclusion

Congratulations! You have taken a complete, multi-service Django application and packaged it into a portable, reproducible environment with Docker and Docker Compose. This is a massive leap forward in making your applications ready for testing, collaboration, and eventually, production deployment.

Key Takeaways:

  • A Dockerfile is a text file that contains instructions to build a single container image.
  • Docker Compose is a tool that uses a docker-compose.yml file to define, run, and connect multiple containers as a single application.
  • Services like PostgreSQL and Redis are typically run from pre-built images from Docker Hub.
  • Volumes are essential for persisting data (named volumes) and enabling a live-development workflow (bind mounts).
  • Configuration and secrets should be passed into containers using environment variables, often managed with an .env file.
  • The docker compose exec command is your tool for running one-off commands (like manage.py migrate) inside a running container.

Preview of the Next Lesson:
Our current docker-compose.yml is perfect for local development. However, running an application in production requires a few more considerations for security, performance, and asset management. In our next lesson, we will configure a Django application for a production environment, covering settings like DEBUG, ALLOWED_HOSTS, and how to handle static files.