# Docker Compose

Declaring a multi-container application in one file — services, networks, volumes and dependencies — and running it with a single command.

**Rests on:** Portainer Management

**Leads to:** Container Orchestration Basics

*MLOps and Platform · Self-Hosting*


## Why learn this

You've mastered running single containers. Now you have a photo service and a search service, and you find yourself starting them in the right order, with the right network and volume connections, every time you reboot. Docker Compose turns that manual dance into a single file that declares every service, its network, its persistent volumes, and its dependencies — and starts the whole stack with one command. This is what makes your self-hosted setup reproducible: a friend can clone your repository, run `docker compose up -d`, and get the exact same system, with the same volumes and networking, on their own machine. It is also the bridge to container orchestration: once your services are declared in a Compose file, moving to Docker Swarm or Kubernetes for multi-host scaling becomes a much smaller step. Concretely, this unlocks: composing a full stack of LLM inference, a vector database for retrieval-augmented generation, and a reverse proxy with TLS — all from one file; declaring health checks and restart policies to make services self-healing; and keeping persistent data safe across container updates.


## The intuition

Think of your self-hosted applications as a household. Each container is a single appliance: a coffee maker, a toaster, a refrigerator. You wouldn't plug each one into a different wall socket with its own separate switch — you'd have a single power strip with a main switch, and you'd arrange the appliances so they share the same counter (the network) and the same pantry (the volume). Docker Compose is that power strip and layout plan. You write down on paper: 'The coffee maker and toaster go on the counter, they share the pantry, and the coffee maker must be turned on before the toaster because toast tastes better with coffee.' That paper is your `docker-compose.yml` file. When you flip the main switch, everything starts in the right order, connected as described, without you turning on each appliance individually.


## An analogy

Imagine you're moving to a new apartment and you have a list of furniture: a bed, a desk, a bookshelf. You also have a list of things that must be in the same room: the desk and the bookshelf in the study, the bed in the bedroom. You have a list of shared utilities: a power outlet in each room, and a storage closet in the hall. Without a plan, you'd move each item one at a time, and you might end up with the bookshelf in the bedroom because you forgot. Docker Compose is the moving checklist: one piece of paper that says, 'Bed in bedroom, desk and bookshelf in study, then place a power strip in each room and share the closet between all.' You follow the checklist and everything ends up where it should, with the shared utilities in place. Where the analogy breaks: unlike furniture, containers are disposable — you can throw one away and a new one can take its place, but the shared storage (volume) stays, holding your data. Also, the checklist can include dependencies: 'Start the study light before the desk, because you need to see to place the books.' In Compose, you declare which service must start before another, and it waits for the dependency to be healthy.


## Definition

Docker Compose is a tool that lets you declare all the containers of an application and how they connect — services, networks, volumes, dependencies — in one text file, and then start, stop, and update the whole stack with a single command.


## How it connects

You already know container basics: you can run a single container with `docker run`, mount a volume for persistent data, and expose a port. Docker Compose builds on that — it's the same operations, but written down in a file so they're reproducible and shareable. It touches two neighbouring topics in your library: **Container Orchestration Basics** — Compose handles service ordering and basic health checks, which is the beginning of orchestration, and **Backup and Hardware Failure** — by declaring volumes in Compose, you make it obvious which data must be backed up, and the single-file nature makes restoring a service on a new machine straightforward. It also fits with **Portainer Management** — you can manage Compose stacks from Portainer's web interface instead of the command line, if you prefer.


## The mathematics

This concept uses no mathematics beyond counting and simple addition. The only 'math' is in the mental model of dependencies: you have a set of services that must start in an order, and you can think of that as a small arithmetic of precedence — service A must be 'up' before service B. That's like saying 1 comes before 2, but it's not numeric, it's logical order. There is no multiplication, no division, no algebra. The entire logic is in the structure of the file, not in equations. So the mathematical section here is deliberately brief: the real 'operation' is writing a YAML file where the indentation defines relationships, and the 'branch' is discrete mathematics — specifically, graph theory, but only the idea of a dependency chain: A depends on B, B depends on C, and you can't start A until B is ready. You can list the services and their dependencies, but you don't need to calculate anything.

Let S = {s₁, s₂, …, sₙ} be the set of services. The start order is a sequence that respects every 'depends_on' condition: for any pair (sᵢ, sⱼ) where sᵢ depends_on sⱼ, sⱼ must appear before sᵢ in the start sequence. No numeric quantity is minimised or maximised.


## A worked example

```
Suppose you have two services: 'web' (a photo gallery) and 'db' (a PostgreSQL database). The web service needs the database to be running before it starts. You have no formulas to compute — the only arithmetic is counting how many services you have, which is 2.

Step 1: Number of services = 2   # You simply count the services you've declared.
Step 2: Dependency: web depends on db → order: db first, then web   # You look at which service needs another and establish the start order.
Step 3: No other calculation needed   # There is no addition of numbers or solving for x; the logic is purely sequencing.

Result: The dependency order is: start 'db', then start 'web'. No arithmetic beyond counting is required.
```


## From scratch, in Python

Let's first understand the building blocks. A **list** is an ordered collection of items, like ["web", "db"]. A **dictionary** is a collection of key-value pairs, like {"name": "web", "port": 5000}. In Python, we can represent a simple Compose file's structure using nested dictionaries and lists.

```python
# Imagine we have three services: web, db, and cache.
# We'll store their names and whether they have persistent data.

services = [
    {"name": "web", "depends_on": ["db"], "volume": None},   # volume is optional
    {"name": "db", "depends_on": [], "volume": "./db_data:/var/lib/postgresql/data"},
    {"name": "cache", "depends_on": [], "volume": None}
]

# Print the service names and their dependencies
def print_services(services):
    for service in services:
        deps = service["depends_on"]
        if deps:
            print(f"{service['name']} depends on {deps}")
        else:
            print(f"{service['name']} has no dependencies")

print_services(services)
```

But this is just a data structure. The real Compose file is a YAML file, which reads almost like a Python dictionary. Here's what a real Compose file looks like — you can think of it as writing that dictionary in a more readable form:

```yaml
version: "3.8"

services:
  web:
    image: nginx:latest
    ports:
      - "8080:80"
    depends_on:
      - db
  db:
    image: postgres:15
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:
```


## The same thing, with a framework

The 'framework' in this case isn't a Python library but the Docker Compose CLI itself. The Python code above just showed you the concepts; now here's the actual command you'd run:

```bash
# Instead of running each container manually with docker run, you run:
docker compose up -d
```

This reads the `docker-compose.yml` file in the current directory, creates the volumes, networks, and containers, and starts them. When you run `docker compose down`, it stops and removes the containers but keeps the volumes (unless you add `-v`).

The dictionary/list structure in Python maps to the YAML structure: `services` is a dictionary of service names to service definitions, each definition is itself a dictionary with keys like `image`, `ports`, `volumes`, and `depends_on` is a list of strings.


## What the code does

The Python code shows a list of dictionaries, where each dictionary describes one service. The `for` loop iterates over the list and prints the name and dependencies. This demonstrates the underlying data model: a Compose file is essentially a nested dictionary. The YAML file is that same dictionary, but written in a format that both humans and the Docker engine can read and parse. The `docker compose` command parses the YAML, converts it to a set of Docker API calls, and performs the same operations you'd do manually with `docker run`.

The walkthrough of the YAML: the top-level `services` key maps to a dictionary. The `web` and `db` are keys in that dictionary. Each service has an `image` (which tells Docker which image to pull), `ports` (a list of host:container port mappings), `volumes` (a list of mount points), and `depends_on` (a list of service names that must be running first). The top-level `volumes` key declares named volumes; the `db_data:` entry creates a volume that will persist even if the container is removed.

When you run `docker compose up`, Docker looks at the file, creates the volumes listed, creates a network (if any is defined), then starts the services in the order defined by `depends_on`. If a service depends on another, it waits until the dependency is healthy (if a healthcheck is defined) or until it's simply started. That's the orchestration part.


## Common misconceptions

**Docker Compose is only for development** — I thought Docker Compose is just a local dev convenience, not for production.

Compose is used in production for small to medium deployments, especially on a single host. It supports restart policies, health checks, logging drivers, resource limits, and secrets — all production concerns. The main limit is that it doesn't scale across multiple hosts, but for a single-server self-hosted setup, it's a fully valid production tool. In fact: Compose is production-grade for single-host deployments; you can use it with a reverse proxy, TLS, and backup tooling.

**Volumes are optional** — If I don't mount a volume, my data is still safe because it's inside the container.

Container filesystems are disposable — when you update the image or recreate the container, all data written to the container layer is lost. Only data in a declared volume or bind mount persists. In fact: Always declare a volume for anything that must survive a container recreation, like database files or model weights.

**`depends_on` waits for the service to be ready** — I set `depends_on`, so the dependent container will wait until the dependency is fully ready and healthy.

Without a `healthcheck` and `condition: service_healthy`, Compose only waits for the container to start running, not for the application inside to be ready. An app can start its process and then take 30 seconds to accept connections. In fact: Add a `healthcheck` to the dependency and use `depends_on: condition: service_healthy` to wait until it's actually ready.

**Compose can run on any host with one command** — If I copy my `docker-compose.yml` to another machine, it will work with no changes.

The file may reference host-specific paths (bind mounts), environment variables, or GPU requirements. Different hosts may have different architectures (ARM vs x86), which affects image availability. Also, some volumes are host-specific if you use named volumes but they're portable; bind mounts are not. In fact: Use portability patterns: use environment variables with defaults, avoid host-specific paths, and test the file on the target host or in CI.

**Compose is Kubernetes with fewer features** — Compose is just a mini-Kubernetes, so learning Compose is a waste if I'm going to learn Kubernetes anyway.

Compose is for single-host, orchestration for multi-host. They share the declarative model, but Compose is far simpler and a great stepping stone. Knowing Compose will make understanding Kubernetes easier, but they're different tools for different scales. In fact: Compose is the right tool for single-host self-hosting; Kubernetes is for scaling across many machines. Learn Compose first — it's simpler and directly useful.

**`docker compose down -v` is safe** — I can always use `down -v` to clean up and it won't delete my data because volumes are separate.

`-v` explicitly deletes named volumes — if your database stores data in a named volume, `down -v` wipes it permanently. It's useful for a clean rebuild, but destructive. In fact: Use `down -v` only when you intend to delete all data. Otherwise, use `down` (keeps volumes) or `stop`.


## In short

Docker Compose lets you write down every container of your application — services, volumes, networks, dependencies — in one file, and run the whole stack with `docker compose up -d`. Make sure to declare volumes for anything you can't lose, and use health checks to avoid the 'started but not ready' trap. It's the standard tool for reproducible single-host deployment, from self-hosted LLMs to edge inference stacks.


## Where this is used

Docker Compose is the workhorse of modern AI system deployment across almost every setting. Here's how it shows up:

**Large Language Models (LLMs).** You're self-hosting an open-weights model like Llama or Mistral on your own GPU server. The full stack is rarely a single container — you have one service running the model itself (e.g., vLLM or Ollama), another running a vector database (like Qdrant or Milvus) for retrieval-augmented generation, and a third running the API gateway that fronts requests to the model. Each of these is a container, and they need to talk to each other over a network you define. In your `docker-compose.yml`, you declare:
```yaml
services:
  model:
    image: vllm/vllm-openai:latest
    ports: ["8001:8000"]
  vector-db:
    image: qdrant/qdrant:latest
    volumes: ["qdrant_storage:/qdrant/storage"]
  api:
    image: my-api:latest
    depends_on: [model, vector-db]
```
The `depends_on` ensures the model and database are up before the API starts, so it can connect without retrying. If you update the model, you just pull a new image and `docker compose up -d` re-creates only that service — the vector database stays untouched, preserving your RAG index. Without Compose, you'd have to manually start each container, remember the network and volume names, and risk starting the API before the database is ready.

**Small models on devices (edge / TinyML).** On a Raspberry Pi or a laptop, you're running lightweight models like Whisper for speech, or a small LLM for on-device translation. Containers are heavier than bare processes, but Compose still appears when you have a bundle: a model server (e.g., `whisper.cpp` server), an audio capture service, and a MQTT broker to coordinate. On a Pi with limited resources, the whole stack must start cleanly after a reboot. You write a Compose file with `restart: always` on each service, and the system brings up the entire pipeline on boot, automatically. The volume declaration mounts the model weights once, so they're not duplicated across container restarts. The official documentation for many edge inference tools recommends Docker Compose precisely for this single-command, reproducible deployment.

**Retrieval-Augmented Generation (RAG).** RAG systems are a natural multi-container architecture — you have an embedding model, a vector store, and the LLM. Docker Compose is the standard way to define all three in one file and connect them via an internal network. The vector store needs persistent storage (volumes), because your document embeddings must survive container updates. The embedding service might need to download a model on first start, so you'd have an init container that prepares the model; Compose's `depends_on: condition: service_completed_successfully` handles that. Every tutorial for building a self-hosted RAG pipeline (e.g., using Ollama + Chroma or Qdrant) uses Compose — it's the de facto standard because it makes the otherwise complicated three-service setup into a single `docker compose up --build`.

**Agentic systems.** An agent that plans and calls tools often runs as multiple services: a planner/controller, a set of tool executors (web search, code interpreter, database query), and a memory/metrics store. These are distinct containers because they need different dependencies and resource limits. The planner must wait for the memory store to be healthy before it starts logging; Compose's `depends_on` + `healthcheck` ensures exactly that. When the agent needs to scale (e.g., more tool executors during peak load), Compose supports `scale` or you can move to swarm mode — but at the small self-hosted scale, Compose's single-file definition keeps the architecture understandable. The tool executors might each have their own volumes (for caches, credentials), and Compose's top-level volumes make it clear what persists.

**Everyday applied ML (forecasting, fraud detection, recommendation).** In a typical data science project, you have a model training job (a container), an inference API (another container), a database for features (a third), and a monitoring dashboard (a fourth). Docker Compose ties them together — you can run the full stack locally on a laptop or on a production server. For example, a fraud detection system might look like:
- `feature-db`: PostgreSQL with input features (volume for persistence)
- `trainer`: container that runs a training script and writes model artifacts to a volume
- `api`: FastAPI app that loads the model and exposes endpoints, depends on `feature-db` being healthy
- `monitor`: Prometheus + Grafana for metrics
Without Compose, coordinating this is error-prone; with it, your entire ML pipeline is a single file you can share with a colleague.

**Speech.** Speech recognition and synthesis systems often have separate services for streaming audio, running the model, and serving the API. Compose manages the audio input service and the model service as separate containers, with a shared volume for audio buffers or model cache. This lets you update the model without touching the streaming logic.

**What a developer actually writes.** They create a `docker-compose.yml` in the project root. The file is the single source of truth for the system. They run `docker compose up -d` to start, `docker compose logs -f` to see logs from all services, `docker compose build` to build images defined in the file, and `docker compose down -v` to tear everything down and delete volumes when they want a clean slate. The most common mistakes are forgetting to declare a volume for persistence (leading to data loss on container removal) and not setting `restart: unless-stopped` (so services die on reboot and never come back). Compose has no built-in security policy; you must set resource limits and read-only filesystems for untrusted code. When done badly — a Compose file with no healthchecks and no volume declarations — you'll find services start in the wrong order and you lose all your data on the first update.


---

Read this on the web: https://basavapurushottam.com/learn-ai/docker-compose
