From multiple
docker runcommands to one declarative YAML file.
A real application rarely consists of just one container.
You might have:
Web App
↓
Database
↓
Redis
↓
Message Queue
Starting and configuring all of these containers manually can quickly become painful.
That's where Docker Compose comes in.
In this article, we'll learn how Docker Compose simplifies multi-container applications and how to define an entire application stack in a single YAML file.
Why Docker Compose Matters
Think back to a typical multi-container setup.
You might need commands like:
docker network create mongo-network
docker volume create mongo-data
docker run -d \
--name mongo \
--network mongo-network \
-v mongo-data:/data/db \
-e MONGO_INITDB_ROOT_USERNAME=admin \
-e MONGO_INITDB_ROOT_PASSWORD=password123 \
mongo
docker run -d \
--name mongo-express \
--network mongo-network \
-e ME_CONFIG_MONGODB_SERVER=mongo \
-e ME_CONFIG_MONGODB_ADMINUSERNAME=admin \
-e ME_CONFIG_MONGODB_ADMINPASSWORD=password123 \
-p 8081:8081 \
mongo-express
It works.
But it's:
- Long
- Difficult to remember
- Easy to mistype
- Hard to share
- Difficult to maintain
Now imagine having six or ten services.
You don't want to manage your application this way.
Docker Compose lets you describe the entire application in a single file.
Then:
docker compose up
And your application stack starts.
That's the real power of Compose.
What Is Docker Compose?
Docker Compose is a tool for defining and running multi-container Docker applications.
Instead of manually running individual containers, you describe your desired application architecture in a YAML file.
For example:
services:
web:
build: .
database:
image: mongo
cache:
image: redis
Compose reads this configuration and creates the required containers, networks, and volumes.
The important idea is:
Define your application once, then let Docker Compose create and manage it.
Imperative vs Declarative
Docker Compose introduces an important DevOps concept.
Imperative
With individual Docker commands, you tell Docker what to do.
docker network create app-network
docker volume create database-data
docker run ...
docker run ...
You're specifying the steps.
Declarative
With Compose, you describe what you want.
services:
web:
build: .
database:
image: mongo
You're saying:
"I want a web service and a database service."
Compose determines how to create them.
This declarative approach becomes extremely important later when you learn:
- Kubernetes
- Terraform
- Infrastructure as Code
- CI/CD configuration
The docker-compose.yml File
Docker Compose uses YAML configuration.
A simple file looks like this:
services:
web:
image: nginx
database:
image: mongo
The main section is:
services:
Each service represents a container.
For example:
services:
web:
image: nginx
database:
image: mongo
cache:
image: redis
This describes three services:
Docker Compose
|
+-------+-------+
| | |
Web Database Cache
nginx mongo redis
YAML Indentation Matters
YAML uses indentation to represent structure.
Use spaces, not tabs.
For example:
services:
web:
image: nginx
ports:
- "8080:80"
Incorrect indentation can cause YAML parsing errors.
Defining a Service
Let's look at a realistic service:
services:
mongo:
image: mongo
container_name: mongo
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: password123
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
Most of these options map directly to docker run flags you've already learned.
image
image: mongo
This tells Compose which Docker image to use.
It's similar to:
docker run mongo
You can also specify a version:
image: mongo:8
Pinning versions is generally better than relying on a moving latest tag for production workloads.
container_name
container_name: mongo
This gives the container a predictable name.
It's similar to:
docker run --name mongo
In Compose projects, you often don't need container_name because Compose already provides predictable service-based naming and DNS.
environment
Environment variables can be defined like this:
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: password123
This is equivalent to:
-e MONGO_INITDB_ROOT_USERNAME=admin
-e MONGO_INITDB_ROOT_PASSWORD=password123
For real applications, avoid committing production secrets directly into your Compose file. Environment files or secret-management solutions are better choices.
ports
Port mappings use the same familiar syntax:
ports:
- "8080:80"
This means:
Host Container
8080 ---> 80
It's the Compose equivalent of:
-p 8080:80
volumes
Volumes provide persistent storage:
volumes:
- mongo-data:/data/db
This connects the named volume mongo-data to /data/db inside the MongoDB container.
At the bottom of the Compose file, declare the volume:
volumes:
mongo-data:
build — Building Your Own Application
For your own application, use build: instead of image:.
Suppose your project looks like this:
my-app/
├── Dockerfile
├── app.py
├── requirements.txt
└── docker-compose.yml
Your Compose file can contain:
services:
web:
build: .
ports:
- "5000:5000"
The:
build: .
means:
Build the image using the Dockerfile in the current directory.
depends_on
Suppose your web application needs MongoDB.
You can write:
services:
web:
build: .
depends_on:
- mongo
mongo:
image: mongo
This tells Compose to start MongoDB before starting the web container.
But remember:
depends_oncontrols startup order, not application readiness.
A database can still be initializing after its container has started.
For more reliable startup behavior, healthcheck can be used.
Docker Compose Networking
One of Docker Compose's biggest advantages is automatic networking.
When you start a Compose project, Compose creates a network for the application by default.
Services can communicate with each other using their service names.
For example:
services:
web:
build: .
mongo:
image: mongo
The web container can connect to MongoDB using:
mongo
as the hostname.
You don't need to know MongoDB's container IP address.
Conceptually:
+-----------------------------+
| Compose Network |
| |
| +--------+ +---------+ |
| | web |-->| mongo | |
| +--------+ +---------+ |
| |
+-----------------------------+
Defining a Custom Network
You can also explicitly define your network:
services:
web:
build: .
networks:
- app-network
mongo:
image: mongo
networks:
- app-network
networks:
app-network:
Now both services belong to app-network and can communicate with each other.
Restart Policies
Compose makes it easy to define what should happen when a container stops.
restart: unless-stopped
Common policies include:
| Policy | Behavior |
|---|---|
no |
Never automatically restart |
always |
Always restart |
unless-stopped |
Restart unless manually stopped |
on-failure |
Restart when the process exits with an error |
For many long-running services:
restart: unless-stopped
is a useful default.
The Most Important Compose Commands
Start the Application
docker compose up
Run in the Background
docker compose up -d
Build Before Starting
docker compose up --build
Check Service Status
docker compose ps
View Logs
docker compose logs
View Logs for One Service
docker compose logs mongo
Follow Logs
docker compose logs -f mongo
docker compose stop vs docker compose down
This distinction is extremely important.
docker compose stop
docker compose stop
Stops the containers but doesn't remove them.
You can start them again with:
docker compose start
docker compose down
docker compose down
Stops and removes the containers and the Compose-created network.
By default, named volumes are preserved.
That means your database data can remain intact.
Be Careful With down -v
You can also run:
docker compose down -v
The -v option removes the Compose-managed volumes.
If your database uses those volumes, your database data can be deleted.
Use this command carefully, especially on production systems.
A Complete Real-World Example
Imagine we have:
- Flask application
- MongoDB
- Mongo Express
- Persistent MongoDB storage
- Custom application network
- Restart policies
Our Compose file can look like this:
services:
web:
build: .
container_name: flask-app
restart: unless-stopped
ports:
- "5000:5000"
depends_on:
- mongo
networks:
- app-network
mongo:
image: mongo
container_name: mongo
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: password123
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
networks:
- app-network
mongo-express:
image: mongo-express
container_name: mongo-express
restart: unless-stopped
environment:
ME_CONFIG_MONGODB_SERVER: mongo
ME_CONFIG_MONGODB_ADMINUSERNAME: admin
ME_CONFIG_MONGODB_ADMINPASSWORD: password123
ports:
- "8081:8081"
depends_on:
- mongo
networks:
- app-network
networks:
app-network:
volumes:
mongo-data:
Now we have an entire application architecture described in one file:
Docker Compose
|
+------------+------------+
| | |
Flask App MongoDB Mongo Express
| | |
+------------+------------+
|
app-network
|
mongo-data
volume
Start everything with:
docker compose up -d
Check the services:
docker compose ps
View all logs:
docker compose logs
Stop everything:
docker compose down
And bring it back:
docker compose up -d
Your MongoDB data remains because the named volume wasn't removed.
Why Compose Is a Big Step Forward
Before Compose, your workflow looked like this:
Remember commands
↓
Create network
↓
Create volume
↓
Start database
↓
Start application
↓
Start admin UI
↓
Check configuration
With Compose:
docker compose up -d
↓
Everything
More importantly, the architecture is now stored as code.
You can:
- Commit it to Git
- Review changes
- Share it with teammates
- Reproduce the environment
- Use it in development
- Use it in CI/CD workflows
That's a major shift in how you manage applications.
Docker Compose Is Infrastructure as Code Practice
You may not think of a Compose file as infrastructure code yet.
But it is teaching you the right mindset.
Instead of:
"I remember how I configured this server."
You say:
"The configuration is defined in Git."
Instead of:
"Run these 12 commands in this exact order."
You say:
"Here is the desired state."
This mindset becomes extremely important when you move into:
- CI/CD
- Kubernetes
- Terraform
- Ansible
- Cloud infrastructure
Docker Compose is one of the first practical places where you'll experience this shift.
Final Thoughts
Docker made it easy to package applications into containers.
Docker networking made it possible for containers to communicate.
Docker volumes made persistent data possible.
And Docker Compose brings all of these concepts together into one manageable application definition.
The biggest lesson today isn't just learning:
docker compose up
It's learning to describe infrastructure declaratively.
Instead of manually managing containers one by one, you define the entire application stack as code and let Docker Compose handle the implementation.
That's the real bridge from simply using Docker to thinking like a DevOps engineer.
Define it once. Version it. Share it. Reproduce it.
Top comments (0)