How to Install Docker Compose on Ubuntu 24.04 — Multi-Container Guide
Quick Summary
Docker Compose is the tool for defining and running multi-container Docker
applications from a single YAML file. This guide covers installing Docker
Compose v2 on Ubuntu 24.04, writing your first docker-compose.yml,
production patterns, and troubleshooting. Estimated time: 10 minutes.>
Skip the setup? Deploy a pre-configured Docker VPS
with Docker and Compose v2 preinstalled in under 2 minutes.
Table of Contents
- What is Docker Compose?
- Docker Compose v1 vs v2
- Prerequisites
- Method 1: Install via Docker Plugin (Recommended)
- Method 2: Install the Standalone Binary
- Method 3: Install via apt (docker-compose-plugin)
- Verify the Installation
- Your First Compose File: WordPress + MySQL
- Essential Compose Commands
- Environment Variables and .env Files
- Networks: Internal vs External
- Volumes: Named vs Bind Mounts
- depends_on and Healthchecks
- Scaling Services
- Production Patterns
- Secrets Management
- Overriding with docker-compose.override.yml
- Running Multiple Projects on the Same Host
- Updating Containers
- Backup Strategy for Volumes
- Troubleshooting
- FAQ
- Next Steps
What is Docker Compose?
Docker Compose is a tool for defining and running multi-container applications.
Instead of typing long docker run commands with flags for ports, volumes,
networks, and environment variables, you describe the entire stack in a single
YAML file called docker-compose.yml. One command — docker compose up — then
starts every service with the right configuration, in the right order, on the
right network.
Compose was originally a separate Python tool (docker-compose), but in 2021
Docker rewrote it as a Go plugin integrated into the Docker CLI. That plugin
is Compose v2, which is the only version supported today. If you are reading
tutorials that use the hyphenated docker-compose command, you are reading v1
material that no longer matches modern Docker installations.
Compose shines for development environments, small-to-medium production deployments, CI pipelines, and single-host stacks like a WordPress site, a Mautic marketing server, or a self-hosted GitLab runner. For multi-host orchestration you eventually graduate to Docker Swarm or Kubernetes, but Compose remains the fastest way to get from a README to a running stack.
Docker Compose v1 vs v2
| Aspect | Compose v1 (legacy) | Compose v2 (current) |
|---|---|---|
| Language | Python | Go |
| Command | docker-compose (with hyphen) | docker compose (space, a plugin subcommand) |
| Packaging | Standalone pip install | Docker CLI plugin |
| Status | End of life, no updates since 2023 | Actively maintained by Docker, Inc. |
| Config file | docker-compose.yml | compose.yaml or docker-compose.yml |
| Speed | Slower, single-threaded | Faster, parallel operations |
docker compose
with a space. If a project still ships a v1 docker-compose.yml, v2 reads it
without changes — the file format is backward compatible.Prerequisites
Before you begin, you need:
- A VPS with at least 1 vCPU, 2 GB RAM, 20 GB storage
- Ubuntu 24.04 LTS (fresh install recommended)
- Docker Engine already installed — if not, follow our
- SSH access with a non-root user in the
dockergroup - A domain name pointed to your server IP (optional, needed for HTTPS)
sudo:docker --version
docker run hello-worldIf the second command prints a "Hello from Docker!" banner, you are ready.
Method 1: Install via Docker Plugin (Recommended)
If you installed Docker using the official get.docker.com script or Docker's
official apt repository, Compose v2 is already installed as a plugin. You do
not need to do anything else — skip to
Verify the Installation.
If you are not sure, run:
docker compose versionIf you see Docker Compose version v2.x.x, you are done. If you see a "command
not found" or "compose is not a docker command" error, continue to Method 3
below to install the plugin via apt.
Method 2: Install the Standalone Binary
Use this method when you need a specific Compose version that is newer than what your distro ships, when you are on a minimal system without apt, or when you are building a portable CI image.
# Get the latest version tag from GitHub
COMPOSE_VERSION=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep tag_name | cut -d '"' -f 4)Create the plugin directory for the current user
mkdir -p ~/.docker/cli-pluginsDownload the binary matching your architecture
curl -SL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" \
-o ~/.docker/cli-plugins/docker-composeMake it executable
chmod +x ~/.docker/cli-plugins/docker-composeTo install system-wide so every user gets Compose, write to
/usr/local/lib/docker/cli-plugins/ instead of ~/.docker/cli-plugins/, and
prefix the mkdir, curl, and chmod commands with sudo.
Verify:
docker compose versionMethod 3: Install via apt (docker-compose-plugin)
If you installed Docker from Docker's official apt repository, you can install Compose as an apt package. This is the cleanest option because apt will keep it updated alongside Docker Engine:
sudo apt update
sudo apt install -y docker-compose-pluginVerify:
docker compose versionExpected output:
Docker Compose version v2.29.7If apt cannot find the package, the Docker official apt repo is not configured. Follow the Docker install guide referenced in the prerequisites to add it.
Verify the Installation
Regardless of which method you used, verify Compose is working end to end:
docker compose version
docker compose --help | head -20The first command prints the version. The second lists every available
subcommand — up, down, logs, ps, exec, build, pull, restart,
and more. If both work, Compose is installed correctly.
Your First Compose File: WordPress + MySQL
Let's prove Compose works with a real example. Create a new project directory
and a docker-compose.yml inside it:
mkdir -p ~/wordpress-stack && cd ~/wordpress-stack
nano docker-compose.ymlPaste this complete, working stack:
services: db: image: mysql:8.0 container_name: wp_db restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: change_me_root MYSQL_DATABASE: wordpress MYSQL_USER: wp_user MYSQL_PASSWORD: change_me_user volumes: - db_data:/var/lib/mysql networks: - backend healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5wordpress: image: wordpress:6-php8.3-apache container_name: wp_app restart: unless-stopped depends_on: db: condition: service_healthy environment: WORDPRESS_DB_HOST: db:3306 WORDPRESS_DB_NAME: wordpress WORDPRESS_DB_USER: wp_user WORDPRESS_DB_PASSWORD: change_me_user volumes: - wp_data:/var/www/html networks: - backend - frontend ports: - "8080:80"
volumes: db_data: wp_data:
networks: backend: internal: true frontend:
Start the stack:
docker compose up -dThe -d flag runs containers in the background (detached). Compose pulls both
images, creates named volumes, builds the two networks, waits for MySQL to pass
its healthcheck, and then starts WordPress. Open
http://YOUR_SERVER_IP:8080 in your browser and walk through the WordPress
setup screen.
Stop it when you are done experimenting:
docker compose downAdd -v to also delete the volumes: docker compose down -v. Without -v,
your database and uploaded files survive so you can bring the stack back up
with the same data.
Essential Compose Commands
Every Compose command runs from the directory that contains your
docker-compose.yml. Here are the ones you will use daily:
| Command | What it does |
|---|---|
docker compose up -d | Create and start all services in the background |
docker compose down | Stop and remove containers, networks (volumes survive) |
docker compose down -v | Same as above but also delete named volumes |
docker compose ps | List containers in this project and their status |
docker compose logs -f | Stream logs from all services (-f follows) |
docker compose logs -f wordpress | Stream logs from one service |
docker compose exec wordpress bash | Open a shell inside a running container |
docker compose restart wordpress | Restart a single service |
docker compose pull | Pull newer versions of every image |
docker compose build | Build images for services with a build: directive |
docker compose config | Validate and print the resolved configuration |
docker compose top | Show running processes inside each container |
Environment Variables and .env Files
Hardcoding passwords in docker-compose.yml is a bad habit. Compose reads an
.env file in the same directory and expands ${VAR} references in the YAML:
nano ~/wordpress-stack/.envMYSQL_ROOT_PASSWORD=supersecret_root
MYSQL_USER=wp_user
MYSQL_PASSWORD=supersecret_user
WP_PORT=8080Then in docker-compose.yml:
services:
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
wordpress:
ports:
- "${WP_PORT}:80"Add .env to your .gitignore so secrets never reach a public repo. Commit
a .env.example with dummy values so teammates know which variables to set.
You can also load variables into a container in bulk with env_file:
services:
app:
image: myapp:latest
env_file:
- ./config/app.envNetworks: Internal vs External
Compose automatically creates one network per project named
<projectname>_default, and every service joins it. Services on the same
network reach each other by service name as DNS. In the WordPress example,
WordPress talks to db:3306 — "db" resolves to MySQL's container IP.
Internal networks have no route to the outside world. Set internal: true
on a network to isolate a database from the internet even if the host's
firewall is misconfigured:
networks:
backend:
internal: trueExternal networks let you share a network between projects. Create the
network once with docker network create proxy_net, then reference it:
networks:
proxy:
external: true
name: proxy_netThis pattern is how reverse proxies like Traefik or Nginx Proxy Manager talk to application containers in other Compose projects on the same host.
Volumes: Named vs Bind Mounts
Compose supports two ways to persist data.
Named volumes are managed by Docker and live under /var/lib/docker/volumes/.
Use them for databases and anything you do not need to edit from the host:
services: db: volumes: - db_data:/var/lib/mysql
volumes: db_data:
Bind mounts map a specific host path into the container. Use them for configuration files or code you edit on the host:
services:
nginx:
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./html:/usr/share/nginx/htmlThe :ro suffix makes the mount read-only — good defense for config files a
container should never modify.
depends_on and Healthchecks
depends_on controls startup order. On its own it only waits for the
dependency container to start, not for the service inside to be ready. A
MySQL container is "started" in seconds but takes longer to accept
connections. Combine depends_on with a healthcheck for reliable ordering:
services: db: image: postgres:16 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 10 start_period: 30s
api: image: myapi:latest depends_on: db: condition: service_healthy
Now api only starts once Postgres answers pg_isready. The start_period
grace window suppresses failures during the initial boot.
Common healthcheck patterns:
| Service | Test |
|---|---|
| MySQL/MariaDB | ["CMD", "mysqladmin", "ping", "-h", "localhost"] |
| Postgres | ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"] |
| Redis | ["CMD", "redis-cli", "ping"] |
| HTTP API | ["CMD", "curl", "-f", "http://localhost:8080/health"] |
Scaling Services
Compose can run multiple copies of a stateless service on the same host:
docker compose up -d --scale worker=4This starts four replicas of the worker service. The service must not bind
a fixed host port — Docker cannot map port 8080 on the host to four containers.
Put a reverse proxy in front (Traefik, Nginx, HAProxy) and let it load-balance
by service name.
For multi-host scaling, Compose itself does not orchestrate across machines — that is where Docker Swarm or Kubernetes come in.
Production Patterns
Restart Policies
restart: unless-stopped is the safest default for production. It restarts a
crashed container automatically but respects manual docker compose stop:
services:
app:
restart: unless-stoppedOther options: no (never), always (even after manual stop), on-failure
(only on non-zero exit, optionally with a retry count).
Resource Limits
Cap CPU and memory so a runaway container cannot starve the rest of the host:
services:
app:
image: myapp:latest
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
memory: 256MCompose respects deploy.resources when running on a plain host — you do not
need Swarm mode for this to work.
Logging Config
By default Docker's json-file driver will grow forever until the disk is
full. Cap it per service:
services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"Production-Ready Template
services: app: image: myapp:1.4.2 container_name: myapp restart: unless-stopped env_file: - ./.env ports: - "127.0.0.1:8080:8080" volumes: - app_data:/data networks: - backend healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 5s retries: 3 start_period: 20s deploy: resources: limits: cpus: "1.0" memory: 512M logging: driver: json-file options: max-size: "10m" max-file: "3"volumes: app_data:
networks: backend:
Note 127.0.0.1:8080:8080 — binding to localhost on the host means only a
reverse proxy on the same host can reach the app. The port is not exposed
publicly, which is exactly what you want behind Traefik or Nginx.
Pin image tags to specific versions (myapp:1.4.2) in production, not
latest. You want predictable updates.
Secrets Management
For truly sensitive values — TLS private keys, API tokens — prefer Docker secrets over environment variables. Compose supports file-based secrets:
services: app: image: myapp:latest secrets: - db_password - api_token
secrets: db_password: file: ./secrets/db_password.txt api_token: file: ./secrets/api_token.txt
Inside the container each secret is mounted as a read-only file under
/run/secrets/<name>. Set filesystem permissions on the host so only root can
read the files on disk. For stronger guarantees use Swarm mode or an external
secret store like HashiCorp Vault.
Overriding with docker-compose.override.yml
If a file named docker-compose.override.yml sits next to docker-compose.yml,
Compose merges it automatically. This is the idiomatic way to split
dev-only settings (code bind mounts, debug ports) from the shared base:
# docker-compose.override.yml — used on dev laptops, not in prod
services:
app:
volumes:
- ./src:/app/src
environment:
DEBUG: "true"
ports:
- "9229:9229" # Node inspectorOn the production server, delete or rename the override file so only the base runs. You can also name it explicitly:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dThe later file wins on conflicts. Use this to layer environment-specific tweaks without duplicating the full stack.
Running Multiple Projects on the Same Host
Each Compose project is identified by the directory name (or by -p /
COMPOSE_PROJECT_NAME). You can run WordPress, Mautic, and a staging clone of
WordPress on the same host as long as you avoid port conflicts.
Two tricks:
WordPress on 8080, Mautic on 8081, WordPress-staging on 8082.
proxy_net and the proxy reaches them by service
name. This scales to dozens of apps.Network isolation still holds: each project has its own default network, and databases stay on internal networks. Only the proxy bridges public traffic in.
Updating Containers
The standard update flow is two commands:
docker compose pull
docker compose up -dpull fetches newer image layers for any tag that has been republished.
up -d recreates containers whose image hash changed and leaves unchanged
services alone. Volumes survive, so your data stays intact.
After the update, prune the old image layers to reclaim disk:
docker image prune -fFor major version bumps (MySQL 5.7 to 8.0, Postgres 15 to 16) read the project's upgrade notes first. Database engines sometimes require running a migration tool against the old data directory before the new version will boot.
Backup Strategy for Volumes
Compose named volumes are just directories under /var/lib/docker/volumes/,
but you should never tar them while the service is running — you risk
copying mid-write files. Safer options:
Database-aware backup (preferred for databases):
docker compose exec db mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --all-databases \
| gzip > /backup/db_$(date +%F).sql.gzStop-copy-start (simple but causes a brief outage):
docker compose stop
tar czf /backup/volumes_$(date +%F).tar.gz -C /var/lib/docker/volumes .
docker compose startLive volume snapshot via a helper container:
docker run --rm \
--volumes-from wp_db \
-v /backup:/backup \
alpine tar czf /backup/wp_db_$(date +%F).tar.gz /var/lib/mysqlSchedule whichever approach you pick in cron and ship the archives off the
host — an S3 bucket, a second VPS, or restic to a backup provider.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Bind for 0.0.0.0:80 failed: port is already allocated | Another container or host service owns the port | Find it with sudo ss -tlnp \</td><td>grep :80<code>, change the host port in </code>ports:, or stop the other service |
permission denied on a bind-mounted volume | Container user UID does not match host file owner | Run sudo chown -R 1000:1000 ./data (or whatever UID the image uses) |
could not resolve host: db inside a container | Services not on the same network | Confirm both list the same entry under networks:, run docker compose config to inspect |
compose is not a docker command | v2 plugin not installed | Install with Method 3: sudo apt install docker-compose-plugin |
container is unhealthy blocks startup | Healthcheck fails repeatedly | Run docker compose logs db and docker inspect --format='{{json .State.Health}}' wp_db \</td><td>jq to see why |
Changes to docker-compose.yml not applied | up only recreates when image changes | Force recreation: docker compose up -d --force-recreate |
| Old images eating disk | Layers left behind after updates | docker image prune -a -f (careful — removes unused images too) |
yaml: line N: mapping values are not allowed here | YAML indentation error | Two-space indent everywhere, no tabs; validate with docker compose config |
FAQ
Q: What is the difference between Docker Compose, Docker Swarm, and Kubernetes?
A: Compose runs multi-container apps on one host — simple, fast to set up, perfect for small-to-medium production workloads. Swarm is Docker's built-in multi-host orchestrator: it takes a Compose file and runs it across a cluster with rolling updates and a built-in load balancer. Kubernetes is the industry-standard orchestrator for large, complex deployments — far more powerful and far more complex to operate. For a single VPS running a handful of apps, Compose is the right tool. Scale up to Swarm or K8s only when you outgrow one host.
Q: Should I still use docker-compose with a hyphen?
A: No. Compose v1 reached end of life and stopped getting updates. Every modern
tutorial, image README, and CI template uses docker compose with a space. If
you have muscle memory for the hyphen, set a shell alias:
alias docker-compose='docker compose'.
Q: Can I use Compose for development and Dockerfiles for building?
A: Yes — that is the standard workflow. Point build: at a directory with a
Dockerfile and Compose will build the image before starting the service:
services:
app:
build: ./app
ports:
- "3000:3000"Run docker compose build to rebuild, or docker compose up --build to
rebuild and start in one shot.
Q: How do I back up a Compose stack?
A: Back up the volumes, not the containers. Containers are disposable — you
can always recreate them from the docker-compose.yml. See the
Backup Strategy for Volumes section above for
database-aware and file-level options.
Q: Is there a GUI for managing Compose stacks?
A: Yes — Portainer is the most popular. It gives you a web UI for every Compose stack on the host, including editing YAML in the browser, viewing logs, and restarting services. Coolify goes a step further with a full Heroku-like PaaS experience on top of Compose.
Next Steps
- How to Install Portainer on Ubuntu — web UI for every Compose stack on your host
- How to Install Coolify on Ubuntu — self-hosted Heroku built on Docker Compose
- How to Install Traefik Reverse Proxy — automatic HTTPS and domain routing for all your stacks
- How to Set Up Automated Backups — never lose a volume
- How to Install Docker on Ubuntu 24.04 — the prerequisite guide
### Skip the Manual Install>
We offer Docker with Compose v2 preinstalled on every VPS plan.
Your server comes ready to docker compose up in under 2 minutes.
>
Deploy a Docker VPS Now | Starting from EUR 7.99/mo | 172+ 1-click apps | 9 global locations