Skip to content

Docker Compose Zero to Hero Example


A realistic Compose project becomes much more useful when you stop treating each feature as an isolated example.

In this final example, you’ll build a small three-tier application from scratch:

Browser
   │
   ▼
Frontend
   │
   ▼
Backend
   │
   ▼
PostgreSQL
   │
   ▼
Named Volume

The frontend and backend use custom Docker images built from Dockerfiles. PostgreSQL uses a pre-built image. Along the way, you’ll make decisions about networking, persistence, configuration, secrets, dependencies, health checks, reusable configuration, multiple Compose files, and include.

The application itself is intentionally small. The complexity belongs in the Compose architecture.

What Are We Building?

The application has three services:

ServiceImagePurpose
frontendBuilt from our DockerfileReceives the browser request and calls the backend
backendBuilt from our DockerfileTalks to PostgreSQL and returns the result
databasePostgreSQL imageStores persistent application data

The request path is:

Browser
   │
   │ HTTP
   ▼
frontend
   │
   │ HTTP
   ▼
backend
   │
   │ SQL
   ▼
database

The frontend needs to reach the backend. The backend needs to reach the database. The frontend does not need direct access to the database.

That gives us a natural network design.

What Does The Project Look Like?

We’ll use this structure:

compose-zero-to-hero/
├── compose.yaml
├── compose.dev.yaml
├── compose.database.yaml
├── .env
│
├── frontend/
│   ├── Dockerfile
│   └── app.py
│
└── backend/
    ├── Dockerfile
    ├── app.py
    └── requirements.txt

Each file has a reason to exist:

compose.yaml
    common application configuration

compose.dev.yaml
    development-specific configuration

compose.database.yaml
    database component configuration

.env
    non-secret configuration values

frontend/
    frontend application + Dockerfile

backend/
    backend application + Dockerfile

How Does The Frontend Work?

The frontend is deliberately tiny. It is a Python HTTP server that calls the backend.

frontend/app.py:

frontend/app.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.request import urlopen
import os


BACKEND_URL = os.environ.get("BACKEND_URL", "http://backend:8000")


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        try:
            with urlopen(BACKEND_URL) as response:
                backend_response = response.read().decode()

            body = f"""
Frontend

{backend_response}
""".strip()

            self.send_response(200)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(body.encode())

        except Exception as error:
            self.send_response(502)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(f"Backend unavailable: {error}".encode())


server = HTTPServer(("0.0.0.0", 8000), Handler)

print("Frontend listening on port 8000")

server.serve_forever()

The application expects the backend to be reachable at:

http://backend:8000

The name backend comes from the Compose service name.

How Does The Backend Work?

The backend also uses Python’s standard library for its HTTP server. It uses psycopg to communicate with PostgreSQL.

backend/app.py:

backend/app.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import os
import psycopg


DB_HOST = os.environ.get("DB_HOST", "database")
DB_NAME = os.environ.get("POSTGRES_DB", "appdb")
DB_USER = os.environ.get("POSTGRES_USER", "appuser")
DB_PASSWORD_FILE = os.environ.get(
    "POSTGRES_PASSWORD_FILE",
    "/run/secrets/postgres_password",
)


def get_database_password():
    with open(DB_PASSWORD_FILE) as password_file:
        return password_file.read().strip()


def get_database_message():
    with psycopg.connect(
        host=DB_HOST,
        dbname=DB_NAME,
        user=DB_USER,
        password=get_database_password(),
    ) as connection:
        with connection.cursor() as cursor:
            cursor.execute("SELECT 'PostgreSQL is working!'")
            return cursor.fetchone()[0]


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        try:
            message = get_database_message()

            body = f"Backend says: Hello from the backend!\nDatabase says: {message}"

            self.send_response(200)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(body.encode())

        except Exception as error:
            self.send_response(503)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(f"Database unavailable: {error}".encode())


server = HTTPServer(("0.0.0.0", 8000), Handler)

print("Backend listening on port 8000")

server.serve_forever()

backend/requirements.txt:

backend/requirements.txt
psycopg[binary]

Notice that the backend doesn’t contain the database password. Instead, it gets the location of the secret from POSTGRES_PASSWORD_FILE.

How Are The Application Images Built?

frontend/Dockerfile:

frontend/Dockerfile
FROM python:3.13-slim

WORKDIR /app

COPY app.py .

EXPOSE 8000

CMD ["python", "app.py"]

backend/Dockerfile:

backend/Dockerfile
FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 8000

CMD ["python", "app.py"]

The image flow is:

frontend/app.py
      │
      ▼
frontend/Dockerfile
      │
      ▼
frontend image


backend/app.py
backend/requirements.txt
      │
      ▼
backend/Dockerfile
      │
      ▼
backend image

PostgreSQL is different. We use a pre-built PostgreSQL image rather than writing a database Dockerfile.

Dockerfiles build the images for our application code. Compose combines those images with pre-built images and configures them into one application.

How Should The Services Communicate?

We have two communication paths:

frontend ───── frontend-network ───── backend

backend ────── backend-network ────── database

This means:

frontend → backend     allowed
backend → database     allowed
frontend → database    not required

Where Does The Database Configuration Live?

compose.database.yaml:

compose.database.yaml
services:
  database:
    image: postgres:17
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets:
      - postgres_password
    networks:
      - backend-network
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"
        ]
      interval: 2s
      timeout: 5s
      retries: 10

secrets:
  postgres_password:
    file: ./secrets/postgres_password.txt

networks:
  backend-network:

volumes:
  postgres-data:

The database uses the PostgreSQL image, receives normal configuration through environment variables, receives its password through a secret, joins the backend network, stores data in a named volume, and has a health check.

Where Does The Main Compose Configuration Live?

compose.yaml:

compose.yaml
include:
  - compose.database.yaml

services:
  frontend:
    build:
      context: ./frontend
    environment:
      BACKEND_URL: http://backend:8000
    networks:
      - frontend-network
    ports:
      - "8080:8000"

  backend:
    build:
      context: ./backend
    environment:
      DB_HOST: database
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD_FILE: ${POSTGRES_PASSWORD_FILE}
    secrets:
      - postgres_password
    networks:
      - frontend-network
      - backend-network
    depends_on:
      database:
        condition: service_healthy
        restart: true

secrets:
  postgres_password:
    file: ./secrets/postgres_password.txt

networks:
  frontend-network:

The included database configuration becomes part of the Compose application, so the backend can use the network defined there.

What Does The Complete Network Look Like?

                 frontend-network
              ┌────────────────────┐
              │                    │
          frontend ───────────── backend
                                      │
                                      │
                              backend-network
                                      │
                                      ▼
                                  database
                                      │
                                      ▼
                               postgres-data

The backend belongs to both networks. The frontend belongs only to frontend-network. The database belongs only to backend-network.

Why Does The Backend Depend On The Database?

The backend executes a SQL query whenever it receives a request.

So:

backend
   │
   │ requires
   ▼
database

The database container being started isn’t enough. PostgreSQL may still be initializing.

We therefore use:

depends_on:
  database:
    condition: service_healthy

and the database has a health check.

The startup sequence becomes:

database container starts
        │
        ▼
PostgreSQL initializes
        │
        ▼
healthcheck succeeds
        │
        ▼
backend starts

Why Does The Backend Have restart: true?

The dependency also contains:

restart: true

If the database is explicitly restarted through a Compose operation, Compose can restart the backend as well.

This is different from a service-level restart policy such as:

restart: unless-stopped

The latter controls restart behavior for that service. The depends_on setting coordinates the dependent service when its dependency is explicitly restarted.

How Do We Handle The Database Password?

Create secrets/postgres_password.txt with your password:

mkdir secrets
echo "change-your-password" > secrets/postgres_password.txt

The database and backend receive this secret as a file.

The backend reads it through:

POSTGRES_PASSWORD_FILE

This means the application doesn’t need to know how the secret is supplied. Compose provides the configuration.

Warning

This example uses a simple local secret file so the Compose mechanics are easy to understand. Don’t commit real credentials to source control.

What Does .env Give Us?

Create .env:

POSTGRES_DB=appdb
POSTGRES_USER=appuser
POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password

Important

Since compose.yaml make password available at /run/secrets/postgres_password we provde it as path for POSTGRES_PASSWORD_FILE. If this app were not to be containerized, you can provide any path in your host where your password lives.

These are normal configuration values, not the actual password.

The distinction is:

.env
   │
   └── normal configuration


secret file
   │
   └── sensitive password

Why Have A Development Compose File?

Development-specific behavior belongs in compose.dev.yaml.

For this project, we’ll use Compose Watch:

compose.dev.yaml
services:
  backend:
    develop:
      watch:
        - action: sync
          path: ./backend
          target: /app

The idea is:

edit backend source
       │
       ▼
Compose Watch
       │
       ▼
sync into container

This isn’t part of the production architecture, so it belongs in the development configuration.

How Do We Start The Application?

From the project directory:

docker compose -f compose.yaml -f compose.dev.yaml up --build

The flow is:

compose.yaml
     │
     ▼
compose.dev.yaml
     │
     ▼
combined configuration
     │
     ▼
build frontend/backend
     │
     ▼
start application

The database configuration is brought in by:

include:
  - compose.database.yaml

So we use both mechanisms for different reasons:

-f
 │
 └── combine environment-specific configuration


include
 │
 └── include a logical application component

What Happens During Startup?

    sequenceDiagram
    participant C as Compose
    participant DB as Database
    participant BE as Backend
    participant FE as Frontend
    participant B as Browser

    C->>DB: Start database
    DB->>DB: Initialize PostgreSQL
    C->>DB: Run health check
    DB-->>C: Healthy
    C->>BE: Start backend
    C->>FE: Start frontend
    B->>FE: HTTP request
    FE->>BE: HTTP request
    BE->>DB: SQL query
    DB-->>BE: Query result
    BE-->>FE: Response
    FE-->>B: Final response
  

What Happens When You Open The Application?

The frontend publishes:

ports:
  - "8080:8000"

So the host’s port 8080 reaches port 8000 inside the frontend container.

Browser
   │
   │ localhost:8080
   ▼
Frontend container :8000
   │
   │ http://backend:8000
   ▼
Backend container :8000
   │
   │ PostgreSQL connection
   ▼
Database container :5432

The backend and database don’t need published host ports. They communicate through the Compose networks.

Publish a port when something outside the Compose application needs to reach the service. Internal services can communicate through their Docker networks without publishing their ports to the host.

How Do We Verify The Application?

Open:

http://localhost:8080

You should see something similar to:

Frontend

Backend says: Hello from the backend!
Database says: PostgreSQL is working!

The response proves that the complete request path worked:

Browser
  ↓
Frontend
  ↓
Backend
  ↓
PostgreSQL

What Happens To Database Data?

The database uses:

volumes:
  - postgres-data:/var/lib/postgresql/data

So:

PostgreSQL container
        │
        ▼
/var/lib/postgresql/data
        │
        ▼
postgres-data volume

The container can be recreated while the volume remains.

container lifecycle
        │
        └── temporary


volume lifecycle
        │
        └── persistent

Can We Recreate The Application?

Stop and remove the application’s containers and networks:

docker compose -f compose.yaml -f compose.dev.yaml down

Then start it again:

docker compose -f compose.yaml -f compose.dev.yaml up --build

The application containers are recreated, while the PostgreSQL volume remains unless you explicitly remove it.

What Does The Final Architecture Teach Us?

    flowchart TB
    Browser["Browser"]

    subgraph Compose["Docker Compose Application"]
        Frontend["Frontend<br/>Custom Image"]
        Backend["Backend<br/>Custom Image"]
        Database["PostgreSQL<br/>Pre-built Image"]
        Volume[("postgres-data<br/>Named Volume")]

        Frontend <-->|frontend-network| Backend
        Backend <-->|backend-network| Database
        Database --> Volume
    end

    Browser -->|localhost:8080| Frontend
  

The configuration has a similar structure:

                         Compose
                            │
          ┌─────────────────┼─────────────────┐
          │                 │                 │
       Services          Networks          Volumes
          │
    ┌─────┼─────┐
    │     │     │
 frontend backend database
    │       │      │
    │       │      ├── healthcheck
    │       │      ├── secret
    │       │      └── volume
    │       │
    │       ├── depends_on
    │       ├── secret
    │       └── environment
    │
    └── published port

Then configuration is separated further:

compose.yaml
     │
     ├── main application
     │
     └── include
            │
            ▼
     compose.database.yaml


compose.dev.yaml
     │
     └── development-only behavior


.env
     │
     └── normal configuration


secrets/
     │
     └── sensitive values

This is the real lesson.

The YAML syntax is only the mechanism. The architecture is the important part.

What Did We Actually Build?

We started with three requirements:

frontend
backend
database

Then we made decisions:

Which services communicate?
        ↓
Two networks

Which data must survive?
        ↓
Named volume

Which images do we build?
        ↓
Frontend + backend Dockerfiles

Which image can we reuse?
        ↓
PostgreSQL image

What configuration changes?
        ↓
Environment variables + development file

Which information is sensitive?
        ↓
Secret

When can backend start?
        ↓
Database health check + depends_on

How should configuration be organized?
        ↓
Multiple Compose files + include

What is useful only during development?
        ↓
Compose Watch

Instead of manually executing a long sequence of Docker commands, we describe the application:

compose.yaml
     │
     ▼
application definition
     │
     ▼
Compose manages the pieces as a unit

What Is The Most Important Mental Model?

Docker gives you the building blocks:

images
containers
networks
volumes

Compose lets you describe how those building blocks form an application:

                    Compose
                       │
       ┌───────────────┼───────────────┐
       ▼               ▼               ▼
    services        networks         volumes
       │
       ├── configuration
       ├── secrets
       ├── health checks
       └── dependencies

Dockerfiles still have their own job:

source code
     │
     ▼
Dockerfile
     │
     ▼
image

Compose then takes those images and assembles the application:

frontend image ──┐
backend image ───┼──► Compose application
PostgreSQL image ┘

Docker gives you containers. Compose lets you describe the application those containers form.

Cleanup

When you’re finished experimenting:

docker compose -f compose.yaml -f compose.dev.yaml down

If you also want to remove the PostgreSQL data created by this example:

docker volume rm compose-zero-to-hero_postgres-data

Warning

Removing the volume permanently removes the database data stored in it. Don’t run the volume removal command if you want to keep the database data.

You can then remove the project directory using your normal Linux file-management command.

Last updated on