--- title: "How to Use Docker Compose on Ubuntu 24.04 with Examples" description: "Learn Docker Compose on Ubuntu 24.04 by building a Python and Redis app with a compose.yaml file, health checks, volumes, networks and environment variables." category: "Docker Kubernetes" language: en source: https://cubepath.com/docs/docker-kubernetes/docker-compose-complete-guide-with-examples updated: 2026-09-25 --- # How to Use Docker Compose on Ubuntu 24.04 with Examples Docker Compose lets you describe a multi-container application (services, networks and volumes) in a single `compose.yaml` file and manage the whole stack with one command. In this tutorial you will build a small Python web application that stores a visit counter in Redis, run it with Docker Compose on Ubuntu 24.04, and learn the commands and file options you will use every day: health checks, startup order, persistent volumes, environment variables and override files. ## Prerequisites To follow this tutorial you need: - A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM. - A non-root user with `sudo` privileges. - Docker Engine and the Docker Compose plugin installed from Docker's official repository. If you have not installed them yet, follow our guide on installing Docker on Ubuntu 24.04 first. - Your user added to the `docker` group, so you can run `docker` without `sudo`. ## Step 1 - Checking the Docker Compose installation Compose v2 ships as a Docker CLI plugin, so the command is `docker compose` (with a space). The old standalone `docker-compose` binary (v1) is no longer maintained and you should not install it. Check that the plugin is available: ```bash docker compose version ``` ```output Docker Compose version v2.39.2 ``` Your version number will differ. If you get `docker: 'compose' is not a docker command`, install the plugin from the Docker repository: ```bash sudo apt update sudo apt install docker-compose-plugin ``` ## Step 2 - Creating the application Create a project directory. Compose uses the directory name as the **project name**, which prefixes every container, network and volume it creates: ```bash mkdir ~/counter cd ~/counter ``` Create the Python application: ```bash nano app.py ``` ```python import os from flask import Flask from redis import Redis app = Flask(__name__) redis = Redis(host=os.environ.get("REDIS_HOST", "redis"), port=6379) @app.route("/") def index(): visits = redis.incr("visits") return f"Hello from Docker Compose! This page has been viewed {visits} times.\n" @app.route("/health") def health(): redis.ping() return "ok\n" ``` The `/` route increments a counter in Redis, and `/health` is a lightweight endpoint the health check will call later. Note that the Redis host is the string `redis`: Compose registers every service name in its internal DNS, so containers reach each other by service name. List the Python dependencies: ```bash nano requirements.txt ``` ```text flask==3.1.0 redis==5.2.1 gunicorn==23.0.0 ``` ## Step 3 - Writing the Dockerfile The `web` service is built from local source, so it needs a Dockerfile: ```bash nano Dockerfile ``` ```dockerfile FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . RUN useradd --create-home --uid 10001 appuser USER appuser EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"] ``` Dependencies are copied and installed before the application code, so editing `app.py` does not force Docker to reinstall packages on every build. The application runs as an unprivileged user and is served by Gunicorn rather than Flask's development server. ## Step 4 - Writing the compose.yaml file Compose looks for `compose.yaml` in the current directory (it also accepts `docker-compose.yml` for backward compatibility). Create it: ```bash nano compose.yaml ``` ```yaml services: web: build: . ports: - "127.0.0.1:${APP_PORT:-8000}:8000" environment: REDIS_HOST: redis depends_on: redis: condition: service_healthy healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"] interval: 10s timeout: 3s retries: 3 start_period: 10s restart: unless-stopped networks: - backend redis: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes"] volumes: - redis-data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 restart: unless-stopped networks: - backend volumes: redis-data: networks: backend: ``` What each part does: - **`services`**: one entry per container. `web` is built from the Dockerfile in the current directory (`build: .`); `redis` uses a published image. - **`ports`**: publishes container port 8000 on the host. Binding to `127.0.0.1` keeps it reachable only from the server itself, which is what you want when a reverse proxy such as Nginx sits in front. `${APP_PORT:-8000}` is variable substitution with a default value. - **`depends_on` with `condition: service_healthy`**: Compose starts `web` only after the Redis health check passes, not merely after the Redis container starts. - **`healthcheck`**: the command Docker runs periodically inside the container. The slim Python image has no `curl`, so the check uses Python's standard library. - **`restart: unless-stopped`**: restarts containers after a crash or a server reboot, unless you stopped them yourself. - **`volumes`**: `redis-data` is a named volume managed by Docker, so the counter survives container recreation. - **`networks`**: both services join the `backend` network. Compose would create a default network anyway, but declaring one makes the topology explicit when you add more services. There is no top-level `version:` key. It is obsolete in Compose v2 and only produces a warning. > Warning: Ports published by Docker are opened through iptables rules that UFW does not see. A mapping such as `"8000:8000"` is reachable from the internet even if UFW blocks port 8000. Bind to `127.0.0.1` unless the port really must be public. Validate the file before starting anything. `docker compose config` prints the fully resolved configuration, or an error pointing at the broken line: ```bash docker compose config --quiet && echo "compose.yaml is valid" ``` ```output compose.yaml is valid ``` ## Step 5 - Starting the stack Build the image and start both services in the background: ```bash docker compose up -d --build ``` ```output [+] Building 14.2s (10/10) FINISHED [+] Running 4/4 ✔ Network counter_backend Created ✔ Volume "counter_redis-data" Created ✔ Container counter-redis-1 Healthy ✔ Container counter-web-1 Started ``` Check the state of the services: ```bash docker compose ps ``` ```output NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS counter-redis-1 redis:7-alpine "docker-entrypoint.s…" redis 30 seconds ago Up 29 seconds (healthy) 6379/tcp counter-web-1 counter-web "gunicorn --bind 0.0…" web 30 seconds ago Up 18 seconds (healthy) 127.0.0.1:8000->8000/tcp ``` Both containers should show `(healthy)` after a few seconds. Now call the application twice: ```bash curl http://127.0.0.1:8000 curl http://127.0.0.1:8000 ``` ```output Hello from Docker Compose! This page has been viewed 1 times. Hello from Docker Compose! This page has been viewed 2 times. ``` ## Step 6 - Managing the stack with everyday commands Run all of these from the project directory (or pass `-f /path/to/compose.yaml`). Follow the logs of every service, or of one service only: ```bash docker compose logs -f docker compose logs --tail 50 web ``` Run a command inside a running service container. For example, read the counter directly from Redis: ```bash docker compose exec redis redis-cli GET visits ``` ```output "2" ``` Restart one service without touching the others: ```bash docker compose restart web ``` Stop and remove the containers and the network. Named volumes are kept: ```bash docker compose down ``` Start the stack again and confirm the counter kept its value, because the data lives in the `redis-data` volume: ```bash docker compose up -d curl http://127.0.0.1:8000 ``` ```output Hello from Docker Compose! This page has been viewed 3 times. ``` To remove the volumes too (this deletes the data), add `-v`: ```bash docker compose down -v ``` Other commands you will use regularly: | Command | What it does | |---|---| | `docker compose up -d` | Create or update containers to match `compose.yaml` | | `docker compose stop` / `start` | Stop or start containers without removing them | | `docker compose build --pull` | Rebuild images, pulling newer base images | | `docker compose pull` | Pull newer versions of the images the stack uses | | `docker compose top` | Show processes running in each container | | `docker compose run --rm web python -V` | Run a one-off command in a new container | ## Step 7 - Using a .env file and variable substitution Compose automatically reads a file named `.env` in the project directory and uses it to substitute `${VARIABLE}` references in `compose.yaml`. Create one to change the published port without editing the Compose file: ```bash nano .env ``` ```ini APP_PORT=8080 ``` Check the substituted value, then apply it: ```bash docker compose config | grep -A4 ports docker compose up -d ``` ```output ports: - mode: ingress host_ip: 127.0.0.1 target: 8000 published: "8080" ``` `docker compose up -d` only recreates the containers whose configuration changed, here `web`. Test the new port: ```bash curl http://127.0.0.1:8080 ``` Keep in mind the difference between the two mechanisms: `.env` feeds substitution in `compose.yaml`, while the `environment:` key (or `env_file:`) sets variables inside the container. Do not commit `.env` files that contain secrets to version control. ## Step 8 - Adding a development override file When a file named `compose.override.yaml` exists next to `compose.yaml`, Compose merges it automatically. That makes it a convenient place for development-only settings. Create one that mounts the source code into the container and enables Gunicorn's auto-reload: ```bash nano compose.override.yaml ``` ```yaml services: web: volumes: - ./app.py:/app/app.py:ro command: ["gunicorn", "--bind", "0.0.0.0:8000", "--reload", "app:app"] ``` Apply it and confirm the merged command: ```bash docker compose up -d docker compose config | grep -A5 "command:" | head -6 ``` Now edits to `app.py` on the host are picked up without rebuilding the image. On a production server, either do not create the override file or ignore it explicitly by naming only the base file: ```bash docker compose -f compose.yaml up -d ``` ## Troubleshooting **`service "web" depends on undefined service` or YAML errors.** Run `docker compose config`. It reports the exact key and line, which is usually an indentation problem. **A container stays `(unhealthy)` or `web` never starts.** Inspect the health check results: ```bash docker inspect --format '{{json .State.Health}}' counter-redis-1 ``` The `Log` array shows the output of the last checks. A `web` container waiting on `service_healthy` will not start until Redis reports healthy. **`Bind for 127.0.0.1:8000 failed: port is already allocated`.** Another container or process already uses the port. Find it with `sudo ss -ltnp 'sport = :8000'` and either stop it or set a different `APP_PORT` in `.env`. **Code changes do not appear.** Without the override file, the code is baked into the image. Rebuild with `docker compose up -d --build`. ## Conclusion You built a two-service application with Docker Compose, wired it together with service-name DNS, controlled startup order with health checks, persisted data in a named volume, and used `.env` and override files to adapt the same stack to different environments. As next steps, put Nginx or Caddy in front of the `web` service to serve it over HTTPS, read our guide on Docker volumes to back up `redis-data`, and configure log rotation so container logs do not fill the disk.