Diving into Docker (Part 5): Deploying apps with `docker-compose`
Learn how to manage multi-service application effectively

I'm a mobile/web developer 👨💻 who loves to build projects and share valuable tips for programmers
Follow me for Flutter, React/Next.js, and other awesome tech-related stuff 😉
In the previous blog...
We saw how we can containerize our application and push it to the Docker Hub registry for anyone to use.
In real world, applications are not that simple, right? There are loads of things like, web server, database, cache, some background worker, etc. And things get complicated quickly. If we try to manage this using docker run command and custom scripts then things become messy and hard to maintain.
This is exactly where Docker Compose helps. Docker Compose lets you describe your entire multi-container application in one simple configuration file, and then deploy everything with a single command. Instead of remembering a dozen commands, you maintain one declarative file. Once the app is running, Compose also gives you a small set of commands to manage the whole app’s lifecycle, like start, stop, restart, view status, and tear it down cleanly. Because the configuration is just a text file, you can also store it in Git and treat it like normal code.
Docker Compose works across platforms, and if you use Docker Desktop on Mac, Compose is typically installed automatically. You can quickly verify it by checking the version with docker-compose --version.
Understanding Compose Files (YAML)
Docker Compose uses a YAML file to define a multi-service application. YAML is popular because it’s human-readable and clean for configuration. Technically, YAML is a superset-like format that can represent the same structures as JSON, and Compose also supports JSON, but YAML is the standard and most common format you’ll see in real projects.
By default, Compose looks for a file named docker-compose.yml. If you want to use a different name, for example, a production-specific file, you can pass the filename using the -f flag when you run Compose.
To make this more understandable, imagine a small app built with three services:
backend
frontend
db
Compose lets you define all the services together so they can be started and managed as one unit.
What’s Inside a docker-compose.yml
Just look at the below docker-compose file.
version: "3.8"
services:
web-fe:
...
redis:
...
networks:
counter-net:
volumes:
counter-volume:
A Compose file is structured into top-level sections (keys). The file includes mostly these four important top-level keys:
versionservicesnetworksvolumes
Other top-level keys also exist (like secrets and configs), but these four are the core ones you’ll see often.
1) version: Compose file format version
The version key is required in this style of Compose file and appears at the top. It defines the version of the Compose file format, you can think of it like the schema/API version for the YAML format. A common mistake is assuming this refers to your Docker Engine version or the Docker Compose binary version it does not. It only describes the format rules for the YAML file itself.
In your example, the Compose file uses version 3.8, which is a widely used format.
2) services: The containers that make up your app
The services section is the heart of Compose. This is where you define each microservice that will run as a container. In the example, there are two services: web-fe and redis.
A really important detail is that Compose uses these service names when it generates container names. So if you define services named web-fe and redis, the containers created will include those names (often combined with the project name, like counter-app_web-fe_1). This naming becomes useful when you’re troubleshooting, checking logs, or inspecting containers later.
3) networks: How containers talk to each other
The networks section tells Docker what networks to create. By default, Compose typically creates bridge networks, which are single-host networks. That means the network connects containers running on the same Docker host. If you need more advanced networking, Compose can reference other drivers, but the default bridge model is perfect for local development and many single-host deployments.
We have the whole dedicated Docker Networking blog upcoming, so stay tuned
In the example, a network named counter-net is defined. Both the service attach to this network, which means they can talk to each other using service names as DNS names.
4) volumes: Persistent data storage
The volumes section defines Docker volumes that should exist for the application. Volumes are used when you want data to survive container restarts and container deletion. This is especially important for databases, but it’s also helpful for development workflows where you want to persist files, cached dependencies, or app state.
In the example, a volume called counter-vol is defined and mounted into the web-fe container. This means some directory inside the container maps to a real persistent storage location managed by Docker.
Breaking Down the Example Services
version: "3.8"
services:
web-fe:
build: .
command: python app.py
ports:
- target: 5000
published: 5000
networks:
- counter-net
volumes:
- type: volume
source: counter-vol
target: /code
redis:
image: "redis:alpine"
networks:
counter-net:
networks:
counter-net:
volumes:
counter-vol:
The web-fe service
The web-fe service is a custom application container. Instead of pulling a prebuilt image from Docker Hub, it uses build: .. That tells Docker: Build an image from the Dockerfile in the current directory. This is a very common development pattern because you keep the application code and Dockerfile together in one repo.
Next, the service defines a command like python app.py. This tells the container what process to run when it starts. In this case, the main process is a Python program called app.py your Flask web app.
It also defines ports, mapping port 5000 inside the container to port 5000 on the host machine. This is what makes the app accessible from your browser. Without a port mapping, the service might run fine but would be isolated inside Docker networking and not reachable directly from your host.
The service attaches to the counter-net network. This is critical because it allows web-fe to communicate with redis over an isolated internal network and it also keeps the service topology tidy instead of throwing everything onto the default network.
Finally, the service mounts a volume. The Compose file mounts counter-vol into /code inside the container. This means files written into /code in the container are actually stored in a Docker managed volume, so they persist beyond the lifecycle of that one container instance.
Putting it all together, Compose deploys one container for web-fe, built from your local Dockerfile, running app.py, exposed on port 5000, connected to the app network, and using persistent storage for the mounted path.
The redis service
The redis service is simpler because it uses an existing image: redis:alpine. This tells Docker to pull the Redis image from Docker Hub if it’s not already present locally.
It also attaches to the same counter-net network, so the web service can talk to it. There’s no need to publish Redis ports to the host unless you specifically want to connect to Redis from outside Docker. Many apps keep Redis internal, accessible only to other containers.
Deploying a Multi-Container App with One Command
Let's clone an example app repo and start it using Compose.
git clone <https://github.com/red-star25/docker_tutorial_compose_example>docker-compose up &
The docker-compose up command is the standard way to bring up an app. When you run it, Compose does several things for you automatically:
Builds images that require building (like the
web-feservice)Pulls images that need downloading (like
redis:alpine)Creates required networks
counter-net)Creates required volumes (
counter-volume)Starts containers in the correct configuration
By default, docker-compose up expects the Compose file to be named docker-compose.yml. If your file is named something else, you must pass it with -f. For example, if your file is something-else.yml, you would run docker-compose -f something-else.yml up.
Many people also start apps in “detached mode” using -d, which runs containers in the background and returns your terminal immediately. So instead of using &, you can run docker-compose up -d. Both approaches give you your terminal back, but -d is the standard Compose way.
After the app starts, you can confirm what happened by listing images, containers, and networks. You’ll notice a new image for the web app (created from your Dockerfile) and a container for each service.
docker container ls
docker image ls
At this point, you’ve successfully deployed a multi-container app using Compose.
Managing the App Lifecycle with Compose
Once the app is running, Compose becomes your control panel.
Stopping and removing: docker-compose down
If you want to stop the app and remove the containers and network it created, you use docker-compose down. This is like the tear down the environment command. It shuts down the services and removes the containers (and usually the default network created for the app).
docker-compose down
A key detail from here is that volumes are not deleted by default when you run down. This is intentional. Volumes are meant to store persistent data, so Docker treats them as long-lived resources. That means if your app wrote data to a volume, the data will still be there the next time you bring the app up.
Also, images you built or pulled remain on the system. This is another reason redeployments become faster because Docker doesn’t need to download or rebuild everything again unless something changed.
Bringing it back quickly: docker-compose up -d
If you run docker-compose up -d again after bringing it down, you’ll often see it start faster. The images are already present and the volume still exists, so Compose only needs to recreate containers and the network, then start everything.
Checking status: docker-compose ps
To see what containers belong to the Compose app and their status (running/stopped), you use docker-compose ps. This is more convenient than docker container ls because it focuses on the services in the Compose project.
Seeing processes inside containers: docker-compose top
If you want to see which processes are running inside each service container, you can use docker-compose top. This is useful when debugging “Is my app actually running?” situations.
Stop without deleting resources: docker-compose stop
Sometimes you just want to pause the app without removing containers, networks, and configuration. docker-compose stopstops all containers in the Compose app but keeps them around. You can then confirm their status using docker-compose ps.
Restarting: docker-compose restart
If you’ve stopped the app (or if something is acting strange), docker-compose restart restarts the services. It’s a convenient way to bounce the entire application without destroying and recreating everything.
Key Docker Compose Commands
To wrap everything up, here’s what the common commands mean in simple terms:
docker-compose up starts the whole app (building/pulling images, creating networks/volumes, starting containers).
docker-compose up -d does the same but runs in the background.
docker-compose ps shows the status of the app’s containers.
docker-compose top shows the processes running inside each container.
docker-compose stop stops the containers but keeps them and their resources.
docker-compose restart restarts containers that belong to the app.
docker-compose down stops and removes the app’s containers
docker-compose rm removes stopped containers
Final Thoughts
Docker Compose is powerful because it turns a messy set of manual Docker commands into one clean, version-controlled application definition. It makes it easy to bring up complex multi-service applications on your machine or on a server, and it gives you a consistent way to manage the application over time.
Once you get comfortable reading and writing Compose files especially understanding services, networks, and volumes you’ll find deploying and managing multi-container apps becomes much simpler and far less error-prone.
See you in the next one, until then...




