Welcome back! In the previous lesson, we saw how to manually create a Docker network and run individual containers on it, enabling them to communicate by hostname. While this is fundamental to understanding how container networking works, you likely noticed it was a tedious process involving multiple long commands (docker network create, docker run ...). Managing a real application with five, ten, or even more services this way would be impractical.
This lesson introduces the solution: Docker Compose. We will transition from imperative commands to a declarative approach. You will learn how to define your entire multi-service application—including all its services, configurations, and networking—in a single, readable YAML file. By the end of this lesson, you'll be able to launch a complex application with a single command.
1. The Problem with Manual Container Management
Before we dive into the solution, let's solidify our understanding of the problem Docker Compose solves. Imagine our e-commerce application has a web frontend, a gRPC user service, a gRPC order service, and a PostgreSQL database. Manually, you'd have to:
- Create a network:
docker network create my_network - Run the database container:
docker run --network my_network --name db ... postgres - Run the user service container:
docker run --network my_network --name user-service ... - Run the order service container:
docker run --network my_network --name order-service ... - And so on...
This process is error-prone, hard to version control, and difficult to share with teammates. There has to be a better way.
2. Introducing Docker Compose
Docker Compose is a tool for defining and running multi-container Docker applications. It uses a YAML file, typically named docker-compose.yml, to configure an application's services. With a single command, you can create and start all the services from your configuration.
To get a high-level overview of what Docker Compose is and the problems it was designed to solve, please watch the introduction of this excellent tutorial from TechWorld with Nana.
Ultimate Docker Compose Tutorial
This video introduces Docker Compose and explains why it is an essential tool for managing multi-container applications.
Watch the first few minutes to understand the purpose of Docker Compose. Pay attention to how it simplifies managing interdependent services like APIs and databases.
3. Anatomy of a docker-compose.yml File
The docker-compose.yml file is the heart of Docker Compose. It's where you declaratively define your application stack. Let's break down its structure.
A docker-compose.yml file typically has a few top-level keys, with services being the most important.

The following video segment demonstrates how the long docker run commands we've seen before are mapped directly to YAML configuration under the services key.
Ultimate Docker Compose Tutorial
This section clearly translates imperative Docker commands into a declarative Docker Compose file.
Watch the part of the video that explains the structure of the docker-compose.yml file. Focus on how each flag from the docker run command (like -p for ports, -e for environment variables) has a corresponding YAML key (ports, environment).
As you saw, the mapping is quite direct. Here are the most common keys you'll use when defining a service:
services: The top-level key that contains the definition for all your individual containers (services).<service-name>: A name you choose for your service (e.g.,api,db,redis). This name is also the hostname the service will use on the Docker network.image: Specifies the Docker image to use for the container. This is used for pre-built images, likepostgres:15orredis:alpine.build: Specifies the build information for an image that needs to be created from a Dockerfile.context: The path to the directory containing the Dockerfile and source code..refers to the current directory.dockerfile: The name of the Dockerfile (if it's not namedDockerfile).
ports: Maps ports between the host machine and the container, in the format"HOST_PORT:CONTAINER_PORT".environment: Sets environment variables inside the container. This is how you pass configuration like database passwords or API keys.volumes: Mounts host paths or named volumes into the container, primarily used for persisting data or providing live code reloading during development.networks: Specifies which networks the service should connect to. If you don't specify this, Docker Compose creates a single default network for you.depends_on: Controls the startup order of services. For example, you can make your API wait for the database to start.
4. A Practical Example: gRPC Service and API Gateway
Let's look at a complete example that mirrors the kind of architecture we are building in this course. The "Python Microservices With gRPC" article provides a perfect docker-compose.yaml for a marketplace service (acting as our API gateway) and a recommendations gRPC service.
Python Microservices With gRPC - Docker Compose
This resource provides a concrete example of defining two microservices using Docker Compose.
Read the section titled "Docker Compose". Study the docker-compose.yaml file provided. Notice how it defines two services: marketplace and recommendations. The marketplace service uses build to create an image from its Dockerfile, sets an environment variable RECOMMENDATIONS_HOST: recommendations to tell it how to find the other service, and maps port 5000. The recommendations service is also built from a Dockerfile. Both services are connected to a user-defined network named microservices.
This example beautifully illustrates the power of Docker Compose:
- Service Definition: Two distinct services are clearly defined.
- Building Images: Both services are built from their respective Dockerfiles using the
builddirective. - Networking is Automated: Docker Compose creates the
microservicesnetwork. The services can communicate using their service names (recommendations) as hostnames because they are on the same network. This automates thedocker network createand--networksteps we did manually. - Configuration via Environment Variables: The
marketplaceservice is configured to find therecommendationsservice via an environment variable. This is a clean, standard practice.
To start this entire application, you would navigate to the project's root directory (where the docker-compose.yaml file is) and run a single command:
docker compose up
Docker Compose reads the file, builds any images that need building, creates the network, and starts the containers in the correct order. To stop and remove everything, another simple command suffices:
docker compose down
5. Hands-on Exercise: Define Your Own Multi-Service App
Now, it's your turn to apply what you've learned. Let's create a docker-compose.yml file for a simple application consisting of a FastAPI backend and a PostgreSQL database.
Scenario:
You have a project directory structured as follows:
.
├── api/
│ ├── main.py
│ └── Dockerfile
└── docker-compose.yml
- The
api/Dockerfilebuilds your FastAPI application, which runs on port8000. - The API needs to connect to a PostgreSQL database.
- The database service should be named
db. - The API service should be named
api.
Your Task:
Write the content of the docker-compose.yml file. It should accomplish the following:
- Define two services:
apianddb. - The
apiservice should be built using theDockerfilein the./apidirectory. - The
apiservice should expose port8000on the host, mapping it to port8000in the container. - The
dbservice should use the publicpostgres:15-alpineimage. - Set the required
POSTGRES_PASSWORDenvironment variable for thedbservice to a value of your choice (e.g.,supersecret). - The
apiservice needs to know the database credentials. Pass the database password to theapiservice as an environment variable namedDATABASE_PASSWORD.
Click here for a solution
version: '3.8'
services:
api:
build:
context: ./api
ports:
- "8000:8000"
environment:
- DATABASE_PASSWORD=supersecret
# You would also pass other DB connection info here, like host, user, etc.
# The hostname for the database is 'db', the service name.
- DATABASE_HOST=db
- DATABASE_USER=postgres
depends_on:
- db
db:
image: postgres:15-alpine
environment:
- POSTGRES_PASSWORD=supersecret
# This is the default user, you could change it if needed
- POSTGRES_USER=postgres
# The default postgres port is 5432. We don't need to expose it to the host
# because the 'api' service can reach it over the internal Docker network.
Key observations from the solution:
depends_on: We added this to ensure thedbcontainer is started before theapicontainer.- Hostname Resolution: Notice the
DATABASE_HOSTenvironment variable for theapiservice is set todb. This works because Docker Compose places both services on the same network, anddbis the service name. - No Port Exposure for DB: We don't need a
portssection for thedbservice because only ourapiservice needs to access it. It's accessible on the internal network without being exposed to the host machine, which is more secure.
Conclusion
You've now learned how to use Docker Compose to define and manage a multi-service application. This is a fundamental skill for microservices development, as it provides a reproducible and easy-to-use environment for both local development and testing.
Key Takeaways:
- Declarative over Imperative: Docker Compose lets you declare the desired state of your application in a YAML file, rather than running a series of manual commands.
- Single File, Single Command: An entire application stack can be defined in
docker-compose.ymland managed with commands likedocker compose upanddocker compose down. - Automated Networking: Services defined in the same file can automatically find each other using their service names as hostnames.
- Standard for Local Development: Docker Compose is the industry standard for spinning up local development environments that mirror a production setup.
Preview of the Next Lesson
We've successfully defined our services in a docker-compose.yml file. In the next lesson, we will dive deeper into two crucial aspects: "Configure service-to-service communication and persistent data volumes using Docker Compose." We'll explore how to ensure data in our database isn't lost when a container restarts and how to mount our source code into a container for live-reloading during development.