Chapter 9: Containerization (Docker)
In the previous chapter, we saw how to handle tasks like sending emails in the background using SAQ and Redis, keeping our main application responsive. We now have several moving parts: our main Litestar web application, a background task worker, a PostgreSQL database, and a Redis cache. Getting all these set up correctly on different computers (your machine, your teammate’s machine, the production server) can be tricky and error-prone. What if you have a different version of Python installed? Or your teammate uses a different operating system?
This is where Containerization with Docker comes in. It solves the classic problem: “But it works on my machine!”
Use Case: How can we package our entire application – the Python code, the database (Postgres), the cache (Redis), the frontend build tools (Node.js), and all their specific versions and dependencies – so that it runs exactly the same way everywhere, whether it’s on your laptop for development, on a test server, or in production?
Why Do We Need Containerization?
Imagine you’re shipping goods overseas. You wouldn’t just load individual boxes, bikes, and barrels onto a ship. They could break, get lost, or react badly with each other. Instead, you pack everything neatly into standardized shipping containers. These containers protect the goods inside, keep them isolated, and can be easily handled and stacked using standard equipment (cranes, trucks, ships) anywhere in the world.
Containerization does the same thing for software. Instead of installing Python, Node.js, Postgres, Redis, and all our application code directly onto every computer’s operating system, we package each part into a separate, isolated “container”.
Docker is the most popular tool for creating and managing these software containers. Docker Compose is a tool for defining and running multi-container Docker applications.
Key Concepts
Let’s break down the main ideas:
1. Container (The Shipping Container)
- What it is: A standardized, isolated package that bundles everything needed to run a piece of software: the code, its specific runtime (like Python 3.11 or Node.js 20), system libraries, and configuration files.
- Isolation: Containers run isolated from each other and from the host computer’s operating system. A library installed for the backend container won’t interfere with the frontend container or your main computer.
- Consistency: A container image, once built, will run exactly the same way wherever you start a container from it.
2. Docker (The Shipping Company)
- What it is: The platform and set of tools used to build, ship, and run containers.
- Docker Engine: The background service that manages containers (starting, stopping, networking).
- Docker Image: A read-only template or blueprint used to create containers. Think of it as the design document for a specific type of packed shipping container (e.g., “Python 3.11 Backend Container Image”). Images are often built in layers.
- Docker Container: A running instance of a Docker image. You can start, stop, and delete multiple containers from the same image. It’s the actual physical shipping container loaded with goods, running on the ship.
3. Dockerfile (The Packing List/Blueprint)
- What it is: A text file containing step-by-step instructions on how to build a specific Docker Image. It defines the base operating system, copies the application code, installs dependencies, sets environment variables, and specifies the command to run when a container starts.
- Analogy: The detailed instructions for assembling a piece of furniture, or the packing list and instructions for correctly loading a specific type of shipping container.
Here’s a highly simplified example of what a Dockerfile might look like for our Python backend:
# File: deploy/docker/dev/Dockerfile (Very Simplified)
# 1. Start from an official Python 3.11 base image
FROM python:3.11-slim
# 2. Set the working directory inside the container
WORKDIR /workspace/app
# 3. Copy dependency files
COPY pyproject.toml uv.lock ./
# 4. Install Python dependencies using 'uv'
RUN pip install uv && uv pip install --system --quiet --no-cache --requirement uv.lock
# 5. Copy the rest of the application code
COPY . /workspace/app
# 6. Expose the port the app runs on
EXPOSE 8000
# 7. Command to run when the container starts (in dev mode)
CMD ["litestar", "run", "--reload", "--host", "0.0.0.0", "--port", "8000"]
- Explanation:
FROM: Specifies the base image (a minimal Linux with Python 3.11).WORKDIR: Sets the current directory for subsequent commands inside the container.COPY: Copies files from your computer into the image.RUN: Executes commands during the image build process (like installing dependencies).EXPOSE: Informs Docker that the container will listen on this port (doesn’t actually publish it).CMD: Defines the default command to execute when a container starts from this image.
4. Docker Compose (The Logistics Manager)
- What it is: A tool for defining and running applications that use multiple Docker containers. It uses a configuration file (usually
docker-compose.yml) to specify all the services (containers), how they connect, which ports they expose, and which volumes they use for persistent data or code mounting. - Analogy: The shipping company’s logistics manager who plans which containers (app, db, cache, worker) go on which ship, how they are connected (network), and where they load/unload persistent cargo (volumes).
Here’s a snippet from docker-compose.override.yml defining the app and db services for development:
# File: docker-compose.override.yml (Simplified Snippet)
services:
app: # The Litestar web application service
build: # Instructions to build the image
context: . # Use current directory
dockerfile: deploy/docker/dev/Dockerfile # Use the dev Dockerfile
ports: # Map host port 8000 to container port 8000
- "8000:8000"
volumes: # Mount local code into container for live updates
- ./src:/workspace/app/src/ # Mounts local 'src' to container's 'src'
# ... other volume mounts ...
depends_on: # Ensure db and cache start first
db:
condition: service_healthy
cache:
condition: service_healthy
command: litestar run --reload --host 0.0.0.0 --port 8000 # Dev command
db: # The PostgreSQL database service
image: postgres:latest # Use an official pre-built image
ports:
- "15432:5432" # Map host 15432 to container 5432 (Postgres default)
environment: # Set environment variables for the database
POSTGRES_PASSWORD: "app"
POSTGRES_USER: "app"
POSTGRES_DB: "app"
volumes: # Store DB data persistently on the host machine
- db-data:/var/lib/postgresql/data
healthcheck: # Check if the database is ready
test: ["CMD", "pg_isready", "-U", "app"]
# ... other healthcheck parameters ...
# Define the volume for persistent DB data
volumes:
db-data: {}
- Explanation:
services: Defines the different parts of our application (app, db, cache, worker).app: Defines the main Litestar application container.build: Tells Compose to build an image using the specifiedDockerfile.ports: Makes the app accessible onhttp://localhost:8000.volumes: This is key for development. It mounts your local project folders directly into the container. When you edit code locally, it’s instantly reflected inside the container, allowing Litestar’s--reloadto work.depends_on: Makes sure theappcontainer only starts after thedbandcachecontainers report they are healthy.
db: Defines the PostgreSQL container.image: Uses a ready-made image from Docker Hub.environment: Configures the database user, password, and name.volumes: Creates a named volumedb-data. Docker manages this volume to store the database files persistently, so your data isn’t lost when the container stops and restarts.healthcheck: Lets Compose know how to check if Postgres is ready to accept connections.
How it Solves the Use Case in litestar-fullstack
Docker and Docker Compose solve our setup and consistency problem perfectly:
- Packaging: The
Dockerfiles (deploy/docker/dev/Dockerfileanddeploy/docker/run/Dockerfile) define exactly how to build the container images for our Litestar app and worker, ensuring the correct Python version, system dependencies (like any needed C libraries), and Python packages are installed inside the image, isolated from your host system. Thedocker-compose.ymluses official images for Postgres and Redis, ensuring consistent versions of those too. - Orchestration: The
docker-compose.ymlfile defines all the services needed:app(Litestar web server),worker(SAQ background tasks),db(PostgreSQL database), andcache(Redis). It configures how they connect via an internal Docker network, which ports are exposed, and how data persists. - Simplified Setup: Instead of manually installing Python, Node, Postgres, Redis, running migrations, and starting each service, a developer just needs Docker installed. Running one command (like
make dev, which often wrapsdocker compose up) builds the images (if needed) and starts all the containers in the correct order with the right configuration. - Consistency: Because everything runs inside containers built from specific images, the application behaves the same way on your machine, your teammate’s machine, and the production server (which would run containers based on the production
Dockerfileanddocker-compose.yml).
Development vs. Production:
docker-compose.override.ymlis used in addition todocker-compose.ymlduring development (docker compose upautomatically merges them). It adds features useful for development, like:- Using the development
Dockerfile(deploy/docker/dev/Dockerfile) which might install extra debugging tools. - Mounting local source code volumes (
./src:/workspace/app/src/) for live code reloading. - Running the app with
--reloadenabled.
- Using the development
- For production (
docker-compose.ymlused alone, potentially with environment variables), you typically:- Use the production
Dockerfile(deploy/docker/run/Dockerfile) which creates a more optimized, smaller image without development dependencies. - Do not mount source code volumes (the code is copied into the image during the build).
- Run the app using a production-grade server like Uvicorn without reload.
- Use the production
Under the Hood: docker compose up
What happens when you run a command like make dev, which usually executes docker compose up --build -d?
- Read Compose Files: Docker Compose reads
docker-compose.ymlanddocker-compose.override.yml(if present and not explicitly excluded). It merges the configurations, with the override file taking precedence. - Check Networks/Volumes: It checks if the Docker networks and volumes defined in the files exist. If not, it creates them (like the
db-datavolume or the default network connecting the services). - Check Images: For each service:
- If the service specifies a
buildinstruction (like ourappandworker), Compose checks if an image needs to be built (due to the--buildflag or if theDockerfileor its context has changed). If so, it runsdocker build, executing the steps in the specifiedDockerfileto create the image layer by layer. - If the service specifies an
image(likedbandcache), Compose checks if that image exists locally. If not, it pulls it from the Docker registry (like Docker Hub).
- If the service specifies a
- Create & Start Containers: For each service, Compose creates a container based on the corresponding image.
- It connects the container to the specified Docker networks.
- It mounts the specified volumes or local directories into the container.
- It sets the environment variables.
- It starts the container, running the
commandspecified in the Compose file or theCMDspecified in theDockerfile. - It maps ports between the host and the container if specified.
- Dependency Management: If
depends_onis used, Compose waits for the dependency containers to be “healthy” (based on theirhealthcheck) before starting the dependent container. - Running: All containers are now running in the background (
-dflag for detached mode), forming your complete application environment. You can access the web app vialocalhost:8000, connect to the database onlocalhost:15432, etc.
Here’s a simplified diagram showing the relationship:
graph TD
User -- Runs --> Command[docker compose up]
Command -- Reads --> ComposeFile[docker-compose.yml + override.yml]
ComposeFile -- Defines --> AppService[Service: app]
ComposeFile -- Defines --> WorkerService[Service: worker]
ComposeFile -- Defines --> DBService[Service: db]
ComposeFile -- Defines --> CacheService[Service: cache]
ComposeFile -- Defines --> Network[Docker Network]
ComposeFile -- Defines --> Volumes["Docker Volumes (e.g., db-data)"]
Command -- Uses --> DockerEngine[Docker Engine]
DockerEngine -- Builds/Pulls --> AppImage[Image: myapp-dev]
DockerEngine -- Builds/Pulls --> WorkerImage[Image: myapp-dev]
DockerEngine -- Pulls --> DBImage[Image: postgres:latest]
DockerEngine -- Pulls --> CacheImage[Image: valkey:latest]
DockerEngine -- Runs --> AppContainer["Container: app (running CMD)"]
DockerEngine -- Runs --> WorkerContainer["Container: worker (running CMD)"]
DockerEngine -- Runs --> DBContainer["Container: db (running postgres)"]
DockerEngine -- Runs --> CacheContainer["Container: cache (running valkey)"]
AppImage -- Used by --> AppContainer
WorkerImage -- Used by --> WorkerContainer
DBImage -- Used by --> DBContainer
CacheImage -- Used by --> CacheContainer
AppContainer -- Connects via --> Network
WorkerContainer -- Connects via --> Network
DBContainer -- Connects via --> Network
CacheContainer -- Connects via --> Network
DBContainer -- Persists data via --> Volumes
Conclusion
Containerization with Docker and Docker Compose is a cornerstone of modern software development and deployment. For litestar-fullstack, it provides:
- Consistency: Ensures the application runs the same way everywhere by packaging dependencies and code into isolated containers.
- Simplified Setup: Reduces the complex setup process to a single
docker compose upcommand (often wrapped inmake dev). - Isolation: Prevents conflicts between project dependencies and your host machine or other projects.
- Environment Management: Easily defines and manages the multiple services (app, worker, database, cache) required by the application.
By using Docker, we create self-contained units for each part of our application, making development easier and deployment much more reliable. Think of it as having pre-fabricated, standardized rooms (containers) that you can easily assemble (compose) to build your house (application) anywhere.
With our application now fully containerized, how do we manage common development tasks like running tests, formatting code, or managing database migrations efficiently?
Next up: Chapter 10: Development & CLI Tools (Makefile, Pre-commit, CLI Commands)
Generated by AI Codebase Knowledge Builder