84.3 docker-compose: Multi-Container Python Apps
Right, so you’ve containerized your Python app. Good for you. But let me guess: it talks to a database, maybe a cache like Redis, and suddenly you’re juggling multiple docker run commands with more flags than a naval parade. It’s a mess. This is where docker-compose comes in – it’s the stage manager for your containerized drama, turning a chaotic backstage scramble into a single, elegant command.
Think of your docker-compose.yml file as a blueprint and a runbook, all in one. It declaratively defines what services (containers) make up your application, how they should be built, their configuration, and, most importantly, how they should talk to each other. No more copying and pasting error-prone commands from a poorly maintained README.
Your First docker-compose.yml
Let’s start with a classic: a Python Flask app and a PostgreSQL database. We’ll create a file named docker-compose.yml. The structure is a YAML dictionary, and the most important top-level key is services, where we define our containers.
version: '3.8'
services:
web:
build: .
ports:
- "5000:5000"
environment:
- DATABASE_URL=postgresql://user:password@db:5432/mydb
depends_on:
- db
volumes:
- .:/app
db:
image: postgres:13
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=mydb
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Let’s break this down. Under services, we have two containers: web and db.
web: This is our Python application.build: .tells Compose to build a Dockerimage from theDockerfilein the current directory.portsmaps port 5000 on your host machine to port 5000 inside the container. This is how you access the app.environmentsets environment variables inside the container. Notice the URL usesdbas the hostname. This is magic. Compose automatically creates a network where services can discover each other by their service name.depends_onis a crucial instruction. It doesn’t wait fordbto be ready (just for it to be running), but it ensuresdbstarts beforeweb. For proper readiness, you need healthchecks in your app.volumesmounts our local code directory (.) into the container’s/appdirectory. This is a development lifesaver, as it lets you edit code locally and see changes immediately without rebuilding the image.
db: The PostgreSQL database.image: postgres:13uses the official image directly—no need to build it.environmentconfigures the default user, password, and database.volumeshere is using a named volume (postgres_data). This is the correct way to persist database data. If you just used a bind mount (./data:/var/lib/...), you’ll likely run into permission issues because the container runs as a specific user. The named volume lets Docker manage it, avoiding this headache.
To run this whole stack, you just navigate to the directory and run:
docker-compose up
Want to run it in the background? docker-compose up -d. Need to rebuild your Python image because you changed dependencies? docker-compose up --build. It’s gloriously simple.
The Devil’s in the Details: Networks, Volumes, and Environment Files
Compose does a lot by default, but you need to understand its magic to debug it.
Networking: As mentioned, Compose creates a default network for you. Each container is reachable at a hostname identical to its service name. This is why our Flask app could connect to
db:5432. This is far simpler than manually creating Docker networks and attaching containers.The .env File: Hardcoding passwords in your
docker-compose.ymlis a terrible idea, especially if you check it into version control. Compose automatically reads a file named.envin the same directory if it exists. You can use this to externalize configuration..envfile:POSTGRES_PASSWORD=supersecretpassword DB_NAME=myappdbUpdated
docker-compose.yml:environment: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${DB_NAME}Now your Compose file is safe to share, and your secrets are (mostly) secure. Just remember to add
.envto your.gitignore!
Going to Production? Pump the Brakes.
Here’s the critical bit: docker-compose is fantastic for development and testing. It is not a production-grade orchestrator like Kubernetes or Docker Swarm. The configuration we just wrote is riddled with development-friendly patterns that are anti-patterns in production.
build: .: You should be using a pre-built, versioned image from a registry in production, not building on the fly.- Binding to localhost ports (
5000:5000): In production, you’d use a reverse proxy (like Nginx or Traefik) and not bind directly to the host network. - Mounting source code (
.:/app): Never, ever do this in production. You want to run the immutable image you built and tested. depends_on: This only controls startup order, not service health. For production resilience, you need proper health checks and an orchestrator that can handle failures.
For production, you’d often write a separate docker-compose-prod.yml that focuses on running pre-built images, setting proper restart policies, and configuring production-specific volumes and secrets. Even then, it’s often just a stepping stone to a full Kubernetes manifest.
So use docker-compose for what it’s brilliant at: taming the complexity of your local development environment. It makes the process reproducible for every developer on your team with a single command. And that, my friend, is no joke.