CoreMesh

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:

text
mkdir compose-demo
cd compose-demo

Create app.py. It reads its Redis connection from environment variables and falls back to defaults:

python
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:

text
flask
redis

Create a Dockerfile (no extension; some editors add .txt and the build then fails). This is the one from the quickstart:

dockerfile
# 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:

text
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:

text
.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:

yaml
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:

text
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:

text
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.

python
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:

yaml
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:

python
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:

yaml
  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:

text
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:

yaml
    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:

text
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:

yaml
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_on only orders startup. If your app needs the database to accept connections, add a healthcheck and condition: service_healthy, as in Step 3.
  • Expecting down to keep data without a volume. down removes containers and their writable layer. Mount a named volume for anything you want to keep.
  • Running down -v casually. 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 localhost as the database host. Inside the web container, localhost is 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 config instead 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, .dockerignore and compose.yaml.
  • Each service has either build: or image:.
  • Config values live in .env and are referenced as ${NAME}.
  • The database service has a healthcheck, and the app waits with condition: service_healthy.
  • Data that must survive down sits on a named volume, declared under top-level volumes:.
  • Use docker compose up --watch while developing and docker compose up -d when you just want it running.
  • Run docker compose config to confirm what Compose will actually apply.
  • Use docker compose down -v only 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.

Saved you an hour? Send a tip.

USDC or ETH on Base. Keeps the notes free and the examples running.

Base
QR code with the CoreMesh tip address on Base
0x9dFD2Ee86182f92464b50809C6702B999a0Cd54f
Open in wallet · 5 USDC

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

`code` and ``` blocks work

No account needed. Comments are read before they show up, usually the same day. Your email is never asked for.

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS