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
sudoprivileges. - 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
dockergroup, so you can rundockerwithoutsudo.
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:
docker compose version
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:
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:
mkdir ~/counter
cd ~/counter
Create the Python application:
nano app.py
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:
nano requirements.txt
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:
nano 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:
nano compose.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.webis built from the Dockerfile in the current directory (build: .);redisuses a published image.ports: publishes container port 8000 on the host. Binding to127.0.0.1keeps 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_onwithcondition: service_healthy: Compose startswebonly 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 nocurl, 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-datais a named volume managed by Docker, so the counter survives container recreation.networks: both services join thebackendnetwork. 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.
WarningPorts 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 to127.0.0.1unless 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:
docker compose config --quiet && echo "compose.yaml is valid"
compose.yaml is valid
Step 5 - Starting the stack
Build the image and start both services in the background:
docker compose up -d --build
[+] 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:
docker compose ps
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:
curl http://127.0.0.1:8000
curl http://127.0.0.1:8000
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:
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:
docker compose exec redis redis-cli GET visits
"2"
Restart one service without touching the others:
docker compose restart web
Stop and remove the containers and the network. Named volumes are kept:
docker compose down
Start the stack again and confirm the counter kept its value, because the data lives in the redis-data volume:
docker compose up -d
curl http://127.0.0.1:8000
Hello from Docker Compose! This page has been viewed 3 times.
To remove the volumes too (this deletes the data), add -v:
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:
nano .env
APP_PORT=8080
Check the substituted value, then apply it:
docker compose config | grep -A4 ports
docker compose up -d
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:
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:
nano compose.override.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:
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:
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:
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.
