Multi-Container Apps with Docker Compose
Most real applications are not just one container. A typical web app needs at least two pieces running together: the app itself, and a database to store data. Starting each one by hand with long docker run commands gets painful fast, and you have to remember every flag every time. Docker Compose fixes this by letting you describe your whole application in a single file (docker-compose.yml) and start everything with one command.
What Docker Compose is
Docker Compose is a tool that reads a YAML file (YAML is a simple, human-readable text format for configuration) and uses it to create and run several containers at once. Each container is called a service in Compose terms. You write the file once, commit it to your project, and anyone on the team can run the exact same setup.
In modern Docker (2026), Compose is built in as a plugin. You run it as docker compose (two words, a space, no hyphen). The old standalone docker-compose (with a hyphen) is deprecated, so use the new form.
If you installed Docker using the official Docker packages on Ubuntu, the Compose plugin is already included. If
docker compose versionsays “command not found”, install it withsudo apt install docker-compose-plugin.
When to use Compose (and when not to)
| Situation | Use Docker Compose? |
|---|---|
| Local development with app + database + cache | Yes — this is the main use case |
| Running a small app on a single server | Yes — simple and reliable |
| A demo or test environment you spin up and tear down often | Yes |
| Production across many servers needing auto-healing and scaling | No — use Kubernetes or a managed service instead |
| A single one-off container with no dependencies | Optional — a plain docker run is fine |
Compose is designed for one host (one machine). It does not spread containers across a cluster of servers. For that you move to an orchestrator like Kubernetes.
Why Compose beats long docker run commands
Imagine starting a Postgres database by hand:
docker run -d --name db \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=appdb \
-v pgdata:/var/lib/postgresql/data \
--network mynet \
postgres:16
Then you start the app, remembering to attach the same network, pass the database URL, map the port, and so on. If you reboot or want to share this with a teammate, you retype all of it. Compose removes that pain:
- The whole setup lives in one version-controlled file.
- Containers on the same Compose project automatically share a network and can reach each other by service name.
- One command brings everything up; one command tears it down.
- Settings are documented and repeatable instead of living in your shell history.
A real compose file: app + Postgres
Here is a runnable example. Create a folder for your project and put this file inside it as docker-compose.yml.
services:
app:
image: nginx:1.27 # replace with your own app image
ports:
- "8080:80" # host port 8080 -> container port 80
environment:
DATABASE_URL: "postgres://appuser:secret@db:5432/appdb"
depends_on:
- db
networks:
- backend
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- backend
restart: unless-stopped
volumes:
pgdata:
networks:
backend:
A few things to notice:
appanddbare services — Compose creates one container for each.- The app’s
DATABASE_URLuses the hostnamedb. Compose creates an internal DNS name for every service, sodbresolves to the database container automatically. You never need an IP address. depends_ontells Compose to startdbbeforeapp. (Note: this only waits for the container to start, not for Postgres to be ready to accept connections — see the gotcha below.)volumeskeeps your database data on disk. A volume is Docker-managed storage that survives even when the container is deleted. Without it, everydocker compose downwould wipe your data.networksputs both services on a private network namedbackendso they can talk to each other but stay isolated from other projects.restart: unless-stoppedrestarts a crashed container automatically, but respects a manual stop.
Never hard-code real passwords in a committed compose file. For anything beyond local testing, move secrets into a
.envfile (referenced with${VARIABLE}) and add that file to.gitignore.
Bringing it up
From inside the project folder, start everything in the background:
docker compose up -d
The -d flag means “detached” — it runs the containers in the background and returns your shell.
Output:
[+] Running 4/4
✔ Network myapp_backend Created
✔ Volume "myapp_pgdata" Created
✔ Container myapp-db-1 Started
✔ Container myapp-app-1 Started
Check what is running:
docker compose ps
Output:
NAME IMAGE COMMAND SERVICE STATUS PORTS
myapp-app-1 nginx:1.27 "/docker-entrypoint.…" app Up 5 seconds 0.0.0.0:8080->80/tcp
myapp-db-1 postgres:16 "docker-entrypoint.s…" db Up 6 seconds 5432/tcp
View logs from all services (follow live with -f):
docker compose logs -f
When you are done, stop and remove the containers and network:
docker compose down
To also delete the named volumes (this erases your database data, so be careful):
docker compose down -v
Common everyday commands
| Command | What it does |
|---|---|
docker compose up -d | Build (if needed) and start all services in the background |
docker compose down | Stop and remove containers + network (keeps volumes) |
docker compose ps | List the running services in this project |
docker compose logs -f db | Follow logs for just the db service |
docker compose exec db psql -U appuser appdb | Open a shell/psql inside a running container |
docker compose restart app | Restart a single service |
docker compose pull | Download newer versions of the images |
The exec command is the one you will use most for debugging. For example, to open a Postgres prompt inside the running database:
docker compose exec db psql -U appuser -d appdb
Output:
psql (16.3)
Type "help" for help.
appdb=#
Best Practices
- Keep one
docker-compose.ymlper project, committed to version control so the whole team runs the same stack. - Store passwords and other secrets in a
.envfile, reference them as${VAR}, and add.envto.gitignore. - Always use a named volume for databases so data survives
docker compose down. - Pin image versions (
postgres:16, notpostgres:latest) so builds are predictable and reproducible. - Don’t rely on
depends_onalone for startup order — add a health check or have your app retry the database connection on boot. - Use
restart: unless-stoppedfor long-running services so they recover after crashes and reboots. - Keep services on an explicit private network and only publish (map) the ports you actually need to expose.