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 VolumeThe 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:
| Service | Image | Purpose |
|---|---|---|
frontend | Built from our Dockerfile | Receives the browser request and calls the backend |
backend | Built from our Dockerfile | Talks to PostgreSQL and returns the result |
database | PostgreSQL image | Stores persistent application data |
The request path is:
Browser
│
│ HTTP
▼
frontend
│
│ HTTP
▼
backend
│
│ SQL
▼
databaseThe 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.txtEach 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 + DockerfileHow Does The Frontend Work?
The frontend is deliberately tiny. It is a Python HTTP server that calls the backend.
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:8000The 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:
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:
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:
FROM python:3.13-slim
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]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 imagePostgreSQL 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 ────── databaseThis means:
frontend → backend allowed
backend → database allowed
frontend → database not requiredWhere Does The Database Configuration Live?
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:
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-dataThe 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
▼
databaseThe database container being started isn’t enough. PostgreSQL may still be initializing.
We therefore use:
depends_on:
database:
condition: service_healthyand the database has a health check.
The startup sequence becomes:
database container starts
│
▼
PostgreSQL initializes
│
▼
healthcheck succeeds
│
▼
backend startsWhy Does The Backend Have restart: true?
The dependency also contains:
restart: trueIf 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-stoppedThe 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.txtThe database and backend receive this secret as a file.
The backend reads it through:
POSTGRES_PASSWORD_FILEThis 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_passwordImportant
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 passwordWhy Have A Development Compose File?
Development-specific behavior belongs in compose.dev.yaml.
For this project, we’ll use Compose Watch:
services:
backend:
develop:
watch:
- action: sync
path: ./backend
target: /appThe idea is:
edit backend source
│
▼
Compose Watch
│
▼
sync into containerThis 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 --buildThe flow is:
compose.yaml
│
▼
compose.dev.yaml
│
▼
combined configuration
│
▼
build frontend/backend
│
▼
start applicationThe database configuration is brought in by:
include:
- compose.database.yamlSo we use both mechanisms for different reasons:
-f
│
└── combine environment-specific configuration
include
│
└── include a logical application componentWhat 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 :5432The 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:8080You 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
↓
PostgreSQLWhat Happens To Database Data?
The database uses:
volumes:
- postgres-data:/var/lib/postgresql/dataSo:
PostgreSQL container
│
▼
/var/lib/postgresql/data
│
▼
postgres-data volumeThe container can be recreated while the volume remains.
container lifecycle
│
└── temporary
volume lifecycle
│
└── persistentCan We Recreate The Application?
Stop and remove the application’s containers and networks:
docker compose -f compose.yaml -f compose.dev.yaml downThen start it again:
docker compose -f compose.yaml -f compose.dev.yaml up --buildThe 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 portThen configuration is separated further:
compose.yaml
│
├── main application
│
└── include
│
▼
compose.database.yaml
compose.dev.yaml
│
└── development-only behavior
.env
│
└── normal configuration
secrets/
│
└── sensitive valuesThis 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
databaseThen 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 WatchInstead of manually executing a long sequence of Docker commands, we describe the application:
compose.yaml
│
▼
application definition
│
▼
Compose manages the pieces as a unitWhat Is The Most Important Mental Model?
Docker gives you the building blocks:
images
containers
networks
volumesCompose lets you describe how those building blocks form an application:
Compose
│
┌───────────────┼───────────────┐
▼ ▼ ▼
services networks volumes
│
├── configuration
├── secrets
├── health checks
└── dependenciesDockerfiles still have their own job:
source code
│
▼
Dockerfile
│
▼
imageCompose 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 downIf you also want to remove the PostgreSQL data created by this example:
docker volume rm compose-zero-to-hero_postgres-dataWarning
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.