Docker Compose Tutorial: Run an App and Database Step by Step
A Docker Compose tutorial with a working compose.yaml for a Flask app and Redis: volumes, .env variables, health checks and the commands you'll use daily.
A Compose file describes your whole stack in one YAML file, and docker compose up builds and starts all of it. This tutorial uses the official quickstart project: a small Flask web app that counts page hits in Redis. It's a real app plus a data store, which is the shape most projects have.
By the end you'll have a compose.yaml with two services, environment variables from a .env file, a health check, a named volume that keeps data, and the commands you'll use every day. Everything here follows the Docker Compose quickstart. You need Docker Compose installed and a basic idea of what images and containers are.
What a Compose file does#
Without Compose you'd start each container by hand, with its own ports, environment and network. Compose lets you declare services (your containers), networks and volumes in a single YAML file, then run the lot with one command.
The file is named compose.yaml. The format is the Compose Specification, which is the recommended one; the old 2.x and 3.x formats were merged into it. The Compose file reference lists the top-level elements: services, networks, volumes, configs and secrets. For this tutorial you only need services and volumes.
Step 1: Set up the project files#
Create a directory and move into it:
mkdir compose-demo
cd compose-demo
Create app.py. It reads its Redis connection from environment variables and falls back to defaults:
import os
import redis
from flask import Flask
app = Flask(__name__)
cache = redis.Redis(
host=os.getenv("REDIS_HOST", "redis"),
port=int(os.getenv("REDIS_PORT", "6379")),
)
@app.route("/")
def hello():
count = cache.incr("hits")
return f"Hello from Docker! I have been seen {count} time(s).\n"
Create requirements.txt:
flask
redis
Create a Dockerfile (no extension; some editors add .txt and the build then fails). This is the one from the quickstart:
# syntax=docker/dockerfile:1
# Build an image with the Python 3.12 image
FROM python:3.12-alpine
# Set the default command for the container to `flask run --debug`
CMD ["flask", "run", "--debug"]
Create a .env file for configuration values:
APP_PORT=8000
REDIS_HOST=redis
REDIS_PORT=6379
Finally, a .dockerignore so your .env and cached bytecode don't get sent to the build:
.env
*.pyc
__pycache__
redis-data
Docker sends the whole project directory to the daemon when it builds an image. Without .dockerignore, that includes .env, which may hold secrets.
Step 2: Write a simple docker compose yml example#
Here's the smallest compose.yaml that runs the app and its database:
services:
web:
build: .
ports:
- "${APP_PORT}:5000"
environment:
- REDIS_HOST=${REDIS_HOST}
- REDIS_PORT=${REDIS_PORT}
redis:
image: redis:alpine
Two services. web is built from the Dockerfile in the current directory and maps host port 8000 to port 5000 in the container, where Flask listens by default. redis uses the public redis:alpine image from Docker Hub.
Start it:
docker compose up
Compose builds the web image, pulls Redis and starts both containers. Open http://localhost:8000 and you should see Hello from Docker! I have been seen 1 time(s). Refresh and the number goes up.
Notice the app connects to the host name redis. That's the service name; services in the same Compose project share a default network and find each other by name.
This version has two known problems, which the next steps fix. Stop it first:
docker compose down
Environment variables and the .env file#
Compose automatically reads .env in the project directory and uses its values to fill in ${...} placeholders in compose.yaml. That's why "${APP_PORT}:5000" becomes "8000:5000".
The environment: list is a separate thing: it sets variables inside the container, which is where app.py reads REDIS_HOST and REDIS_PORT. In this setup the .env value is interpolated into the Compose file, then handed to the container.
Keeping values out of the YAML helps in three ways, per the quickstart: you can change values across environments without editing YAML, you avoid committing secrets, and you can reuse one value across several services.
The snippet below mimics the placeholder substitution so you can see what happens to the port mapping. It's a toy model, not how Compose is implemented.
import re
env_file = """APP_PORT=8000
REDIS_HOST=redis
REDIS_PORT=6379
"""
compose_line = '- "${APP_PORT}:5000"'
values = dict(line.split("=", 1) for line in env_file.splitlines() if line)
resolved = re.sub(r"\$\{(\w+)\}", lambda m: values[m.group(1)], compose_line)
print("before:", compose_line)
print("after: ", resolved)
Step 3: Make web wait for the database#
In the first version, web starts at the same time as redis. If Redis isn't ready, Flask can't connect and crashes. A health check plus depends_on fixes that:
services:
web:
build: .
ports:
- "${APP_PORT}:5000"
environment:
- REDIS_HOST=${REDIS_HOST}
- REDIS_PORT=${REDIS_PORT}
depends_on:
redis:
condition: service_healthy
redis:
image: redis:alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
What each healthcheck field means:
| Field | Meaning |
|---|---|
test |
Command run inside the container. redis-cli ping expects PONG. |
start_period |
Grace period before checks begin. Failures in it don't count toward retries. |
interval |
How often the check runs after the start period. |
timeout |
How long each check has to respond. |
retries |
Consecutive failures allowed before the container is marked unhealthy. |
With interval: 5s and retries: 5, Compose waits up to 25 seconds before giving up. A quick way to check that arithmetic:
interval = 5
retries = 5
start_period = 10
print("max wait after the start period:", interval * retries, "seconds")
print("start period (failures ignored):", start_period, "seconds")
Run docker compose up again. You should see the Redis container reported as Healthy before web starts. Then docker compose down.
Step 4: Persist data with a named volume#
Right now the counter resets whenever you run docker compose down and then up. down removes the containers, and data in a container's writable layer goes with them. docker compose stop keeps the containers, so data survives that, but you shouldn't rely on it where containers get replaced.
A named volume stores the data outside the container lifecycle. Add it to the redis service and register it at the top level:
redis:
image: redis:alpine
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
volumes:
redis-data:
redis-data:/data mounts the volume at /data, where Redis writes its data files. The top-level volumes: key registers it with Docker.
To test it: bring the stack up, refresh the page a few times, run docker compose down, bring it up again, and refresh. The counter continues where it left off.
To wipe it deliberately:
docker compose down -v
The -v flag removes named volumes along with the containers. It permanently deletes the stored data, so use it on purpose.
Step 5: Live updates with Compose Watch#
Without it, every code change means stopping, rebuilding and restarting. Add a develop.watch block to web:
develop:
watch:
- action: sync+restart
path: .
target: /code
- action: rebuild
path: requirements.txt
The first rule copies changed files from the project directory into /code in the running container and restarts it. The second triggers a full image rebuild when requirements.txt changes, because new packages need a new image, not just synced files.
Start with watching on:
docker compose up --watch
Edit the greeting in app.py, save, and Compose logs Syncing service "web" after changes were detected. Refresh the page and the new text appears, with the counter still climbing.
Splitting a big file with include#
When one compose.yaml gets crowded, the top-level include element lets you move services into another file. Put the Redis service and the redis-data volume in infra.yaml, then reference it:
include:
- path: ./infra.yaml
services:
web:
build: .
ports:
- "${APP_PORT}:5000"
environment:
- REDIS_HOST=${REDIS_HOST}
- REDIS_PORT=${REDIS_PORT}
depends_on:
redis:
condition: service_healthy
develop:
watch:
- action: sync+restart
path: .
target: /code
- action: rebuild
path: requirements.txt
Compose merges both files at startup, and web can still reach redis by name because included services share the default network. For a two-service project I'd skip this. It pays off when different teams own different parts of the stack or you reuse infrastructure across projects.
Everyday docker compose commands#
These are the commands that appear in the quickstart:
| Command | What it does |
|---|---|
docker compose up |
Builds images if needed, creates and starts all services |
docker compose up -d |
Same, in detached mode so your terminal stays free |
docker compose up --watch |
Starts the stack with Compose Watch enabled |
docker compose stop |
Stops containers but keeps them, so container data survives |
docker compose down |
Removes the containers; named volumes stay |
docker compose down -v |
Removes containers and named volumes, deleting stored data |
docker compose config |
Prints the fully resolved configuration |
The quickstart's last step also covers streaming logs and running commands inside a running container; the full command list is linked from there.
Make docker compose config a habit. It works purely from your files and doesn't need the stack running. The output shows your .env values already substituted, short port notation like "8000:5000" expanded into target, published and protocol, network and volume names prefixed with the project name (compose-demo), and any included files merged into one view. When a variable or merge isn't doing what you expect, start there.
Common mistakes and gotchas#
- Starting order is not readiness. A plain
depends_ononly orders startup. If your app needs the database to accept connections, add ahealthcheckandcondition: service_healthy, as in Step 3. - Expecting
downto keep data without a volume.downremoves containers and their writable layer. Mount a named volume for anything you want to keep. - Running
down -vcasually. It deletes named volumes permanently. - Dockerfile named
Dockerfile.txt. Some editors append the extension, and the build fails. - Committing
.env. If it holds secrets, keep it out of version control and out of your image with.dockerignore. - Using
localhostas the database host. Inside thewebcontainer,localhostis the container itself. Use the service name (redis) as the host. - Guessing at the resolved config. When a port or variable looks wrong, run
docker compose configinstead of staring at the YAML. - Indentation. YAML is whitespace sensitive. Use spaces and keep each service's keys aligned.
Quick reference checklist#
- Project has
app.py,requirements.txt,Dockerfile,.env,.dockerignoreandcompose.yaml. - Each service has either
build:orimage:. - Config values live in
.envand are referenced as${NAME}. - The database service has a
healthcheck, and the app waits withcondition: service_healthy. - Data that must survive
downsits on a named volume, declared under top-levelvolumes:. - Use
docker compose up --watchwhile developing anddocker compose up -dwhen you just want it running. - Run
docker compose configto confirm what Compose will actually apply. - Use
docker compose down -vonly when you mean to delete the data.
FAQ#
The FAQ follows below.
Quick answers
Is there a docker compose tutorial for beginners I can follow end to end?
The official [Compose quickstart](https://docs.docker.com/compose/gettingstarted/) builds a Flask and Redis app step by step, and this article walks through the same project. You only need Docker Compose installed and a basic grasp of containers.
What should the compose file be named?
The quickstart uses compose.yaml in the project directory. The format is the Compose Specification, described in the [Compose file reference](https://docs.docker.com/reference/compose-file/).
Why did my counter reset after I restarted the stack?
docker compose down removes the containers and the data in their writable layer. Mount a named volume (such as redis-data:/data) and declare it under top-level volumes:. Only docker compose down -v removes that volume.
How do I stop the app from crashing because the database isn't ready?
Add a healthcheck to the database service and set depends_on with condition: service_healthy on the app. Compose then waits until the database reports healthy before starting the app.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base. Keeps the notes free and the examples running.

0x9dFD2Ee86182f92464b50809C6702B999a0Cd54f
On a computer? Scan the code with your phone wallet, or copy the address.
USDC or ETH on Base works best. Other tokens on Base, Ethereum or Arbitrum also reach this address. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note