You will learn:
- How to define a multi-container application using
docker compose - How to connect services via an internal network
- How to use environment variables and a
.envfile - How to use volumes for data persistence
- How to configure an application without modifying source code
Theoretical Introduction
In the previous exercise, we created a custom Docker image and ran a single container using the docker run command. This approach is suitable for simple applications consisting of a single service.
In practice, however, applications usually consist of multiple parts:
- web application
- database
- cache (e.g. Redis)
- reverse proxy server
- and other services
Running each service separately with docker run would be messy and hard to maintain.
Docker Compose was created for this purpose.
Docker Compose allows you to:
- define multiple services in a single file (
docker-compose.yml) - automatically create an internal network between services
- manage volumes
- use environment variables
- start the entire application with a single command
In this exercise, we will create a simple application consisting of:
- a Flask web application
- a PostgreSQL database
The web application will store the visit count in the database.
Application Architecture
User (browser)
↓
web (Flask)
↓
db (PostgreSQL)
The services will communicate over an internal Docker network.
The service name in Compose automatically becomes its DNS name.
1. Creating the Application
First, we will prepare a simple Flask application that:
- connects to the database
- creates a table (if it does not exist)
- increments a counter on each visit
- displays the visit count
Create the file app.py:
import os
import psycopg2
from flask import Flask
app = Flask(__name__)
DB_HOST = os.getenv("DB_HOST", "db")
DB_NAME = os.getenv("POSTGRES_DB", "appdb")
DB_USER = os.getenv("POSTGRES_USER", "appuser")
DB_PASS = os.getenv("POSTGRES_PASSWORD", "secret")
APP_MESSAGE = os.getenv("APP_MESSAGE", "Hello from Docker Compose!")
def get_connection():
return psycopg2.connect(
host=DB_HOST,
database=DB_NAME,
user=DB_USER,
password=DB_PASS
)
@app.route("/")
def index():
conn = get_connection()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS visits (
id SERIAL PRIMARY KEY
);
""")
cur.execute("INSERT INTO visits DEFAULT VALUES;")
conn.commit()
cur.execute("SELECT COUNT(*) FROM visits;")
count = cur.fetchone()[0]
cur.close()
conn.close()
return f"<h1>{APP_MESSAGE}</h1><p>Visit count: {count}</p>"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
2. Defining Dependencies
Create the file requirements.txt:
Flask==3.0.2
psycopg2-binary==2.9.9
3. Preparing the Docker Image for the Web Service
Now we will create a Dockerfile that builds the image for our application.
This image:
- uses the official Python image
- installs dependencies
- copies the application
- starts the Flask server
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]
4. Configuration Using Environment Variables
Create the file .env:
POSTGRES_DB=appdb
POSTGRES_USER=appuser
POSTGRES_PASSWORD=supersecret
APP_MESSAGE=Hello from Docker Compose!
Docker Compose will automatically load this file.
The advantages are:
- configuration can be changed without modifying code
- the same Compose file can be used in different environments
5. Defining Multiple Services with Docker Compose
Now we will create the docker-compose.yml file.
The file can read values from .env. ${VARIABLE} means the value is loaded from .env.
The docker-compose.yml file contains the following sections:
servicesdefines the individual containersdbuses a ready-made image from Docker Hubwebis built from the local Dockerfiledepends_onensures thedbcontainer starts beforeweb; however, it does not guarantee that PostgreSQL is ready to accept connections — that is whywebhasrestart: alwayssetvolumesensures data persistence
services:
db:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
web:
build: .
ports:
- "5000:5000"
depends_on:
- db
environment:
DB_HOST: db
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
APP_MESSAGE: ${APP_MESSAGE}
restart: always
volumes:
db_data:
6. Starting the Entire Application
Try a few commands to start the application and verify it is working.
Start the application and watch what happens. Open your browser at:
http://localhost:5000
Refresh the page several times. The visit count will increase.
Use these commands:
Build and start:
docker compose up --build
Start in the background:
docker compose up -d --build
Check status:
docker compose ps
View logs:
docker compose logs -f
7. Verifying Data Persistence
Stop the application:
docker compose down
Start it again:
docker compose up -d
The visit count will be preserved.
This is because we use a named volume:
volumes:
- db_data:/var/lib/postgresql/data
If you were to use:
docker compose down -v
the volume will be deleted and the database will be re-initialized.
8. Bonus Tasks
- Try changing the configuration:
Change the value in .env:
APP_MESSAGE=New message from the environment!
Then restart only the web service:
docker compose up -d web
The application will display the new message without changing the source code and without needing to rebuild the image.
- Try running a command inside a running container:
docker compose exec db psql -U appuser -d appdb
- Try scaling the service:
docker compose up --scale web=3
10. Shutdown
Stop the application:
docker compose down
Summary
In this exercise, you learned how to:
- create a multi-container application
- use Docker Compose
- connect services via an internal network
- use a
.envfile - work with volumes
- configure an application using environment variables
Docker Compose greatly simplifies the development and testing of complex applications and enables a reproducible environment with a single command.