Docker
Docker Compose

Published: October 13, 2026 · 11 min read

Docker Compose Tutorial — Run 3 Containers With One File

Everything so far ran one container at a time. Real apps don't work like that: a to-do app is a website, an API and a database — three containers that must find each other, start in the right order, and survive a reboot. This Docker Compose tutorial takes that exact app from our class session on EC2: one docker-compose.yml describing all three services, the two real problems we hit on the way (docker compose not existing, and the disk filling up mid-pull), and the single command that brings the whole stack to life.

In this guide, you will:

  • See why 4 fragile commands become one file and one command
  • Read a real docker-compose.yml line by line — services, image, ports, volumes, networks
  • Understand the key idea: service names are the addresses your code connects to
  • Fix "docker: unknown command: docker compose" — installing the plugin when apt can't find it
  • Master the commands: up, up -d, ps, logs -f, restart, down

Docker Compose explained — four fragile docker run commands by hand versus one docker-compose.yml file and one docker compose up command

Why Docker Compose Exists

With what you already know from the networks and volumes lessons, you can run this app by hand. Here's what that costs:

terminal — the by-hand way
# THE PROBLEM — a 3-container app started by hand

docker network create todo-net

docker run -d --name mongo --network todo-net \
  -v ./mongo-data:/data/db mongo:7

docker run -d --name server --network todo-net \
  srinudev645/todo-server:1.0.0

docker run -d -p 80:80 --name client --network todo-net \
  srinudev645/todo-client:1.0.0

# 4 commands, in the RIGHT order, with the RIGHT flags —
# every time you deploy, on every machine.
# One typo in --network and nothing connects.

Four commands that must be typed in the right order, with the right flags, every single time — and nobody else can tell what the app is made of without reading your shell history. Compose replaces all of it with a file you can read, edit, commit to git, and hand to anyone.

The docker-compose.yml File, Line by Line

Here's the real file from the class project — three services, one network, one bind mount:

docker-compose.yml
# docker-compose.yml — the same app, described once

services:
  mongo:
    image: mongo:7
    networks:
      - todo-net
    volumes:
      - ./mongo-data:/data/db

  server:
    image: srinudev645/todo-server:1.0.0
    networks:
      - todo-net

  client:
    image: srinudev645/todo-client:1.0.0
    ports:
      - "80:80"
    networks:
      - todo-net

networks:
  todo-net:

Docker Compose YAML file anatomy showing what services, image, ports, volumes and networks each mean

Read it top-down and every docker run flag you know has a home here: image: is the image name, ports: is -p, volumes: is -v, networks: is --network. The structure is just indentation — two spaces in means "belongs to the line above".

⚠️

Notice that only client has ports. The API and the database get no port at all — they're reachable only from inside the network. That's the secure default: publish a port only for what genuinely must face the internet.

Service Names Are the Addresses

This is the idea that makes Compose click, and it builds directly on the custom bridge networks lesson:

Docker Compose app architecture — browser reaches the client on port 80, client calls server:5000, server calls mongo:27017, all on one private network

how the services address each other
# WHY THE SERVICE NAMES MATTER

# inside the server's code:
mongodb://mongo:27017/todos
#         └── "mongo" = the SERVICE NAME in docker-compose.yml

# inside the client's nginx config:
proxy_pass http://server:5000;
#                 └── "server" = the SERVICE NAME

# Compose creates the network and registers every service
# name in it — so the names just work, with no IPs anywhere.

# Rename a service in the YAML and the app breaks:
# the names in the file ARE the addresses in your code.

Compose creates the network and registers each service name on it. So the API's connection string is literally mongodb://mongo:27017 — the word mongo comes straight from the YAML. No IP addresses, which matters because container IPs change on every restart.

Step 1 — Get the Project

terminal — clone the project
# STEP 1 — get the project onto the server

mkdir docker-compose
cd docker-compose

git clone https://github.com/webdevelopersrinu/DOCKER.git
cd DOCKER/to-do-list

ls -l          # client/  server/  docker-compose.yml
cat docker-compose.yml

Step 2 — "docker compose" Doesn't Exist Yet

First real problem of the class. Docker is installed and working, but docker compose isn't a command:

terminal — the missing plugin
# STEP 2 — "docker compose" doesn't exist yet

docker compose up -d
# docker: unknown command: docker compose

# Compose is a PLUGIN — Docker itself is installed,
# but the compose plugin is a separate piece:
docker --version        # Docker version 29.1.3  ✓ Docker is fine

# the usual install, which may or may not work:
sudo apt install docker-compose-plugin -y
# Error: Unable to locate package docker-compose-plugin

Compose v2 ships as a CLI plugin, separate from Docker itself. When apt can't find the package, install the plugin file directly — this works on any Ubuntu:

terminal — install the compose plugin
# THE FIX — install the plugin by hand (works on any Ubuntu)

sudo mkdir -p /usr/local/lib/docker/cli-plugins

sudo curl -fSL \
  https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 \
  -o /usr/local/lib/docker/cli-plugins/docker-compose

sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
#    └── without this, the file exists but can't run

docker compose version    # now it answers ✓

# Docker looks in the cli-plugins folder for extra commands —
# drop the file there and "docker compose" exists.

Why /usr/local/lib/docker/cli-plugins? Docker scans that folder for extra sub-commands. Any executable named docker-compose in there becomes docker compose. That's also why chmod +x isn't optional — without it the file is there but can't run.

Step 3 — One Command Starts Everything

terminal — attached
# docker compose up — stays attached to your terminal

docker compose up

# every service's logs stream in together, tagged by name:
#   client-1 | ...
#   server-1 | ...
#   mongo-1  | ...
#
# Ctrl+C stops the whole app.
# Best while learning or debugging — you see everything at once.

What docker compose up does in order — read the file, pull images, create the network, create containers, start everything

terminal — what Compose reports
# WHAT COMPOSE DOES, IN ORDER

# ✔ Image mongo:7                        Pulled
# ✔ Image srinudev645/todo-server:1.0.0  Pulled
# ✔ Image srinudev645/todo-client:1.0.0  Pulled
# ✔ Network to-do-list_todo-net          Created   ← made for you
# ✔ Container to-do-list-mongo-1         Created
# ✔ Container to-do-list-server-1        Created
# ✔ Container to-do-list-client-1        Created

# names follow a pattern: <folder>-<service>-<number>
# so you always know which app a container belongs to.

And the payoff, visible right in the logs — the services found each other by name, exactly as the YAML promised:

terminal — proof it works
# PROOF — the services found each other BY NAME

# in the logs:
# server-1 | MongoDB connected at mongodb://mongo:27017/todos
# server-1 | Server running at http://localhost:5000
# mongo-1  | Waiting for connections  port: 27017
# client-1 | nginx ... ready for start up

# no IP addresses anywhere — just the service names.

# then open the app:
#   http://<your-ec2-public-ip>
#   (port 80 — remember to open it in the security group)

The Docker Compose Commands

The Docker Compose commands — up -d starts the whole stack, down removes it, plus ps, logs -f and restart

terminal — compose commands
# THE COMPOSE COMMANDS — same verbs, whole app

docker compose up -d        # start everything in the background
docker compose ps           # which services are up?
docker compose logs -f      # follow the logs of ALL services
docker compose logs -f server   # ...or just one service
docker compose restart      # restart the stack
docker compose down         # stop + remove containers and network

# down keeps your data; add -v only if you want it gone:
docker compose down -v      # also deletes the volumes — careful!

Docker Compose Quick Reference

In docker-compose.ymlSame thing in docker run
image: mongo:7the image name at the end
ports: - "80:80"-p 80:80
volumes: - ./mongo-data:/data/db-v ./mongo-data:/data/db
networks: - todo-net--network todo-net
the service name (mongo)--name mongo and its DNS address
CommandWhat it does
docker compose upstart everything, logs attached to your terminal
docker compose up -dstart everything in the background
docker compose pswhich services are running
docker compose logs -ffollow all logs (add a service name for one)
docker compose restartrestart the stack
docker compose downstop and remove containers + network (data kept)
docker compose down -v...and delete the volumes too — data gone

What's Next

You can now describe a whole application in one file and start it anywhere with one command: services with their images, ports only where the outside world needs in, volumes for data that must survive, and networks that make mongo:27017 and server:5000 work without a single IP address. You also fixed the thing that actually blocks you on a fresh server — the missing Compose plugin. The official Compose documentation (opens in a new tab) covers the remaining keys (environment, depends_on, restart), and the Compose file reference (opens in a new tab) is worth bookmarking as your YAML grows.

← Back to Docker Volumes

Continue to Docker Swarm →


Frequently Asked Questions

What is Docker Compose and why do I need it?

Docker Compose is a tool that describes a multi-container application in one YAML file and starts all of it with one command. Real apps are not a single container: a to-do app is a website, an API and a database, which by hand means creating a network and running three docker run commands in the right order with the right flags, every time, on every machine. With Compose you write docker-compose.yml once and run docker compose up — it pulls the images, creates the network, creates every container and starts them. The file is also documentation: anyone can read it and see exactly what the app is made of.

What is the difference between docker compose and docker-compose?

docker-compose with a hyphen is the old standalone Python tool (Compose v1), installed as its own program. docker compose with a space is Compose v2, a plugin built into the Docker CLI — it is the current version and the one you should use. They take the same docker-compose.yml file, so commands translate directly: docker-compose up becomes docker compose up. If docker compose says "unknown command", the plugin is simply not installed yet — Docker itself is fine.

How do I install Docker Compose on Ubuntu when apt cannot find the package?

On some Ubuntu releases sudo apt install docker-compose-plugin fails with "Unable to locate package", because that package only exists in Docker's own repository and not in Ubuntu's. The reliable fix is to drop the plugin binary into Docker's plugin folder yourself: sudo mkdir -p /usr/local/lib/docker/cli-plugins, then sudo curl -fSL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose, then sudo chmod +x on that file. The chmod is essential — without the execute permission the file exists but cannot run. Verify with docker compose version.

How do containers in Docker Compose talk to each other?

By service name. Compose creates a private network for the app and registers every service's name on it as a DNS name, so the API connects to the database at mongodb://mongo:27017 and the web server proxies to http://server:5000 — where mongo and server are exactly the service names written in docker-compose.yml. No IP addresses appear anywhere, which matters because container IPs change on every restart. The consequence is that renaming a service in the YAML breaks the app unless you change the connection strings too: in Compose, the names in the file are the addresses in your code.

Why does only one service need the ports setting in Docker Compose?

Because ports only controls access from the outside world. Services on the same Compose network already reach each other by name on any port, so the API and the database need no ports entry at all. In the to-do app only the client has ports: "80:80", which makes the website reachable from a browser; the API on 5000 and MongoDB on 27017 stay private inside the network, unreachable from the internet. That is good security by default — publish a port only for what genuinely has to face the public, and leave everything else internal.

What is the difference between docker compose up and docker compose down?

docker compose up reads docker-compose.yml and creates plus starts everything in it — pulling missing images, creating the network and creating one container per service. Without -d it stays attached and streams every service's logs into your terminal; with -d it runs detached in the background, which is what you want on a server. docker compose down is the mirror image: it stops and removes the containers and the network that up created. Your data survives a down because volumes are kept by default; docker compose down -v also deletes the volumes, which destroys the data.

How does Docker Compose name the containers it creates?

Compose names each container with a three-part pattern: the project name, the service name and a number — so the to-do-list folder running a service called server produces to-do-list-server-1. The project name comes from the folder the docker-compose.yml sits in, which is why two different projects can both have a service named server without clashing. The number at the end exists because a service can be scaled to several copies. You will see these names in docker ps, while Compose's own commands let you use the short service name instead: docker compose logs -f server.