Containerize a Multi-Service App

Orchestrate a Flask and PostgreSQL app with Docker Compose.

Introduction

30 Second Summary

Every real-world application is actually multiple programs working together. Your web app talks to a database, your database stores the data, and in production they each run in their own isolated container.

In this project, you will containerize a Flask API backed by PostgreSQL, orchestrate both services with Docker Compose, and progressively harden the setup with healthchecks, multi-stage builds, and network isolation.

What You'll Build

You will run a single command and watch a task-tracker API spin up alongside its database, then create and retrieve tasks via curl to prove the whole stack is working.

By the end of this project, you'll have:

  • A running multi-container application where you type docker compose up and both a Flask API and PostgreSQL database start together, ready to accept requests.
  • A production-optimized Docker image that went from over 400MB to under 100MB through multi-stage builds, proving how much bloat a naive Dockerfile carries.
  • A compose.yaml with healthchecks, dependency ordering, and network isolation so the database only accepts connections once healthy and stays invisible to the outside world.
  • Secret Mission: Add a Redis caching layer toggled by Compose profiles, so optional services only run when you need them.

Are there any prerequisites?

You should have basic Docker knowledge (images, containers, docker run) and be comfortable navigating a Linux terminal. You do not need to be a Python expert. The application code is intentionally minimal and provided for you.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your container orchestration journey.

Scaffold the Project and Build the Naive Image

Before you can containerize anything, you need the application code itself. In this step, you will create a minimal Flask API, pin its dependencies, and write a naive Dockerfile that packages everything into a single image.

This naive image will be your baseline. By the end of the project, you will shrink it dramatically using multi-stage builds.

In this step, get ready to:
  • Create the project directory with a Flask API, its dependencies, and a naive Dockerfile.
  • Add a .dockerignore to keep unnecessary files out of the build context.
  • Build the naive Docker image and observe its size.
Create the project directory and application code

You need a dedicated folder on your Desktop for this project. All files will live inside a single task-tracker directory.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-tracker
cd task-tracker
  • Open this folder in VS Code by running:
code .

VS Code didn't open?

If code . returns "command not found", open VS Code manually, then press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type "Shell Command: Install", and select the option to install the code command in your PATH.

help me set up the code command in my terminal

Now you will create the Flask application. This is a minimal API with two endpoints for creating and listing tasks.

  • Create a new file called app.py in VS Code and paste the following code:
import os
from flask import Flask, request, jsonify
import psycopg2
from psycopg2.extras import RealDictCursor

app = Flask(__name__)

def get_db_connection():
    # Connect to PostgreSQL using environment variables with fallback defaults
    conn = psycopg2.connect(
        host=os.environ.get("DB_HOST", "db"),
        port=os.environ.get("DB_PORT", "5432"),
        dbname=os.environ.get("DB_NAME", "taskdb"),
        user=os.environ.get("DB_USER", "postgres"),
        password=os.environ.get("DB_PASSWORD", "postgres"),
    )
    return conn

def init_db():
    # Create the tasks table if it doesn't already exist
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS tasks (
            id SERIAL PRIMARY KEY,
            title TEXT NOT NULL,
            created_at TIMESTAMP DEFAULT NOW()
        )
    """)
    conn.commit()
    cur.close()
    conn.close()

What does this code do?

  • get_db_connection() builds a connection to PostgreSQL using environment variables. The defaults (db, taskdb, postgres) match the service names and credentials you will set in Docker Compose later.
  • init_db() creates a tasks table with an auto-incrementing ID, a title, and a timestamp. The IF NOT EXISTS clause means it is safe to call on every startup.
  • Still in app.py, paste the following code below the init_db function:
@app.route("/tasks", methods=["GET"])
def get_tasks():
    # Fetch all tasks from PostgreSQL, newest first
    conn = get_db_connection()
    cur = conn.cursor(cursor_factory=RealDictCursor)
    cur.execute("SELECT id, title, created_at FROM tasks ORDER BY created_at DESC")
    tasks = cur.fetchall()
    cur.close()
    conn.close()
    return jsonify(tasks)

@app.route("/tasks", methods=["POST"])
def create_task():
    # Validate the request body contains a title
    data = request.get_json()
    if not data or "title" not in data:
        return jsonify({"error": "title is required"}), 400
    # Insert the new task and return it with a 201 status
    conn = get_db_connection()
    cur = conn.cursor(cursor_factory=RealDictCursor)
    cur.execute(
        "INSERT INTO tasks (title) VALUES (%s) RETURNING id, title, created_at",
        (data["title"],),
    )
    task = cur.fetchone()
    conn.commit()
    cur.close()
    conn.close()
    return jsonify(task), 201

if __name__ == "__main__":
    init_db()
    app.run(host="0.0.0.0", port=5000)

What does this code do?

  • GET /tasks queries the database and returns all tasks as JSON, sorted newest first.
  • POST /tasks accepts a JSON body with a title field, inserts a new row, and returns the created task with a 201 status.
  • The if __name__ == "__main__" block calls init_db() to ensure the table exists, then starts Flask on port 5000.
  • Save app.py.
  • Confirm app.py exists by running:
ls

You should see app.py listed in the output.

✔️ Awesome, I've got everything!

Great. Confirm you have saved app.py and move on.

ⓧ I'd like to double check the full code

Scroll up and confirm both blocks are present in your app.py, in this order:

  • Block 1: Imports, get_db_connection(), and init_db() (the database setup).
  • Block 2: get_tasks(), create_task(), and the if __name__ entry point (the routes and startup).

Next, you need a file that pins your Python dependencies so the Docker build is reproducible.

  • Create a new file called requirements.txt in VS Code and paste this content:
Flask==3.1.3
psycopg2-binary==2.9.12

Why pin exact versions?

Pinning versions (==) means your image builds identically every time, regardless of when you run it. Without pins, a future release of Flask or psycopg2 could introduce breaking changes.

  • Save requirements.txt.
  • Create a new file called Dockerfile (no extension) in VS Code and paste this content:
# Use the full Python 3.12 image as the base
FROM python:3.12

WORKDIR /app

# Install dependencies first (leverages Docker layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the application code
COPY app.py .

EXPOSE 5000

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

What does this Dockerfile do?

  • FROM python:3.12 pulls the full Python image as the base layer. This image includes the entire Python toolchain, compilers, and system libraries.
  • COPY requirements.txt is placed before COPY app.py so that Docker caches the dependency install layer separately from your code changes.
  • CMD ["python", "app.py"] tells Docker what to run when the container starts.
  • Save Dockerfile.
  • Confirm all three files exist by running:
ls

You should see app.py, Dockerfile, and requirements.txt in the output.

✔️ Awesome, I've got everything!

Great. Move on to the next substep.

ⓧ I'd like to double check the full code

Here is the full Dockerfile for comparison:

FROM python:3.12

WORKDIR /app

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

COPY app.py .

EXPOSE 5000

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

And the full requirements.txt:

Flask==3.1.3
psycopg2-binary==2.9.12

Missing a file?

Make sure each file is saved inside the task-tracker folder, not a subfolder. Check the VS Code Explorer sidebar to confirm they sit at the top level.

help me figure out why my files are not showing up in ls

Create the .dockerignore file

A .dockerignore file tells Docker which files to exclude from the build context. Without it, Docker copies everything in the directory into the image, including caches, version control history, and virtual environments.

  • Create a new file called .dockerignore in VS Code and paste this content:
__pycache__
*.pyc
.git
.venv
.env
*.log

What does this exclude?

  • __pycache__ and *.pyc are compiled Python bytecode files that the container regenerates anyway.
  • .git is your version control history, which adds size without any runtime value.
  • .venv and .env are local development artifacts that should never leak into a container image.
  • Save .dockerignore.
  • Confirm all four files exist by running:
ls -a

You should see app.py, Dockerfile, requirements.txt, and .dockerignore in the output. The -a flag shows hidden files (those starting with a dot).

Build the naive image and check its size

With all the source files in place, you can build your first Docker image. This uses the full python:3.12 base image, which includes the entire Python toolchain.

  • Build the Docker image and tag it as task-tracker:naive by running:
docker build -t task-tracker:naive .

What does this command do?

  • docker build reads your Dockerfile and executes each instruction as a layer.
  • -t task-tracker:naive tags the result with the name task-tracker and the version label naive.
  • The . at the end sets the build context to the current directory (everything .dockerignore does not exclude).

The build will take a moment as Docker downloads the python:3.12 base image and installs your pip packages.

Before you check the image size, how large do you think it will be? The base python:3.12 image alone includes compilers and system libraries.

  • Check the image size by running:
docker images task-tracker:naive

You will see the image is over 400MB. That is a lot of weight for a two-endpoint Flask API.

Why is it so large?

The python:3.12 base image is a full Debian distribution with compilers, headers, and tools your app never uses at runtime. In Step 4, you will use multi-stage builds to strip all of that away and drop the image to under 100MB.

Build failed with a permissions error?

Make sure Docker Engine is running. On Linux, your user must be in the docker group or you need to prefix the command with sudo.

If you see a message about being unable to connect to the Docker daemon, start Docker Desktop or run sudo systemctl start docker.

help me fix my Docker build error

Your project is scaffolded and you have a baseline image at 400MB+. Next up, you will try to run this container and discover what happens when the app cannot find its database.

Run the App and Introduce Docker Compose

In the previous step, you built the task-tracker:naive image and saw its hefty 400MB+ size. Now it is time to actually run it.

Your app needs PostgreSQL to store tasks. Running the container alone means there is no database to connect to. This gap reveals why Docker Compose exists: to orchestrate multiple containers that depend on each other.

In this step, get ready to:
  • Run the standalone container and watch it crash without a database.
  • Create a compose.yaml defining two services: the API and PostgreSQL.
  • Start the full stack with Docker Compose and observe a race condition.
Run the container standalone

Your app.py calls init_db() at startup, which immediately tries to connect to PostgreSQL. Inside this single container, there is no PostgreSQL server running.

Before you run the next command, predict what will happen. Do you think the app will start successfully, or will it crash?

  • Attempt to run the container by running this command:
docker run --rm -p 5000:5000 task-tracker:naive

The container exits almost immediately with a Python traceback. The last line mentions a connection error because no PostgreSQL server exists at host db on port 5432.

Why did it crash?

The get_db_connection() function uses psycopg2.connect() to reach PostgreSQL at host db. Inside a standalone container, there is nothing listening on that hostname. The connection is refused and the app crashes before it can serve any requests.

This is the exact problem Docker Compose solves. It lets you define multiple services that start together on the same network, so one container can reach another by service name.

Create the Compose file

Docker Compose uses a single YAML file to define all the services, networks, and volumes your application needs. You describe your infrastructure as code, and Compose handles spinning everything up.

  • Create a new file called compose.yaml in your task-tracker folder by running:
touch compose.yaml
  • Confirm the file exists by running:
ls

You should see compose.yaml listed alongside app.py, Dockerfile, and your other project files.

  • Open compose.yaml in VS Code and paste the following:
services:
  api:
    build:
      context: .
    ports:
      - "5000:5000"
    environment:
      DB_HOST: db
      DB_PORT: "5432"
      DB_NAME: taskdb
      DB_USER: postgres
      DB_PASSWORD: postgres

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: taskdb
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

What does this file do?

  • api service: builds from your local Dockerfile, maps port 5000 to the host, and passes database connection details as environment variables.
  • db service: pulls the official postgres:17-alpine image, sets up credentials, and persists data in a named volume called pgdata.
  • The DB_HOST: db environment variable tells the API to reach PostgreSQL at the hostname db. Compose automatically creates a network where each service is reachable by its name.
  • The volumes: section at the bottom declares pgdata as a named volume so your database data survives container restarts.
  • Save compose.yaml.

YAML not looking right?

YAML is indentation-sensitive. Make sure you are using spaces (not tabs) and that nested keys are indented by exactly 2 spaces. If Compose complains about syntax, double-check that services: is at the top level with no leading spaces.

Help me fix my compose.yaml syntax

✔️ Awesome, I've got everything!

Great. Double check you have saved compose.yaml before moving on.

ⓧ I'd like to double check the full code

services:
  api:
    build:
      context: .
    ports:
      - "5000:5000"
    environment:
      DB_HOST: db
      DB_PORT: "5432"
      DB_NAME: taskdb
      DB_USER: postgres
      DB_PASSWORD: postgres

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: taskdb
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
Start the stack and observe the race condition

With both services defined, Compose can start them together on a shared network. The API should now be able to reach PostgreSQL at the hostname db.

Before you run the next command, predict: will everything work perfectly now that the database exists on the same network?

  • Start the full stack by running:
docker compose up --build

Watch the terminal logs. Compose pulls the PostgreSQL image, builds your API image, and starts both containers. You may see one of two outcomes:

  • The API crashes with the same connection refused error you saw earlier, because it started before PostgreSQL finished initializing.
  • The API starts successfully because PostgreSQL happened to be ready first.

Either outcome proves the same problem. There is no guarantee the database is ready before the API tries to connect. This is a startup race condition.

What is a race condition?

A race condition happens when two processes depend on timing that is not guaranteed. Here, Compose starts both containers in parallel. If PostgreSQL finishes its initialization before the API calls init_db(), the connection succeeds. If not, it fails.

The result is unpredictable. It might work on your machine today and crash in production tomorrow. The next step introduces healthchecks to solve this reliably.

  • Stop the running stack by pressing Ctrl+C in the terminal.

Don't see any logs at all?

Make sure you ran docker compose up --build from inside the task-tracker directory where compose.yaml lives. Compose looks for the file in the current directory.

Help me troubleshoot docker compose

You have seen the app fail without a database, then introduced Compose to provide one. But the race condition means the stack is still fragile. Next up, you will add healthchecks and dependency ordering to guarantee the database is ready before the API starts.

Add Healthchecks and Dependency Ordering

In the previous step, you saw the race condition firsthand. The API tried to connect to PostgreSQL before the database was ready to accept connections.

The fix is a two-part pattern. First, you give the database a healthcheck so it can report when it is actually ready. Second, you tell Docker Compose to wait for that healthcheck to pass before starting the API.

In this step, get ready to:
  • Add a healthcheck to the PostgreSQL service so it reports its own readiness.
  • Wire the API service to wait for the database to be healthy before starting.
  • Restart the stack and confirm the API can create and retrieve tasks.
Add a healthcheck to the database service

A healthcheck is a command that runs inside the container at a regular interval. If the command exits with status 0, the container is considered healthy.

  • In VS Code, open compose.yaml.
  • Find the db service. Add the following healthcheck block after the volumes section:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

What does this healthcheck do?

  • test runs pg_isready -U postgres inside the container. This PostgreSQL utility checks whether the server is accepting connections.
  • interval: 5s means Compose runs the check every 5 seconds.
  • timeout: 3s gives each check 3 seconds to respond before marking it as failed.
  • retries: 5 means the container must fail 5 consecutive checks before being marked unhealthy.
  • start_period: 10s gives PostgreSQL a 10-second grace period on initial startup. Failures during this window do not count toward the retry limit.
Add dependency ordering to the API service

Now that the database can report its health, you need the API to listen for that signal. The depends_on key with condition: service_healthy tells Compose to hold back the API until the database passes its healthcheck.

  • In the same compose.yaml file, find the api service. Add this block after the environment section:
    depends_on:
      db:
        condition: service_healthy

What does this change?

Without this line, Compose starts the API as soon as the db container is running. "Running" does not mean "ready". With condition: service_healthy, Compose waits until the healthcheck passes before creating the API container.

  • Save compose.yaml.

YAML indentation tripping you up?

YAML is whitespace-sensitive. The healthcheck key must be at the same indentation level as image, environment, and volumes in the db service. Similarly, depends_on must align with build, ports, and environment in the api service.

help me fix YAML indentation errors in my compose.yaml healthcheck and depends_on

Restart the stack and verify the fix

Before you run the next command, think about what you expect to see. Will the API start immediately, or will Compose hold it back until something happens first?

  • Tear down the existing stack and rebuild by running:
docker compose down && docker compose up --build

Watch the terminal output. You will see Compose start the db container first and run the healthcheck. Only after the database reports healthy does the api container start. The API logs should show it connecting to PostgreSQL successfully.

What should I see in the logs?

Compose creates the db container and begins polling with pg_isready. After roughly 10-15 seconds (the start_period plus one successful check), the database transitions to healthy. Only then does Compose create and start the api container.

The API logs will show Flask starting on port 5000 without any connection errors. That is the proof that the race condition is solved.

  • Open a second terminal in the task-tracker directory. Create a task by running:
curl -X POST http://localhost:5000/tasks -H "Content-Type: application/json" -d '{"title": "Learn Docker Compose"}'

You should see a JSON response containing the new task with an id, title, and created_at field.

  • Retrieve all tasks by running:
curl http://localhost:5000/tasks

You should see a JSON array containing your "Learn Docker Compose" task. The API is talking to the database reliably.

Still seeing connection errors?

If the API crashes on startup, check that the healthcheck block is nested under the db service (not at the top level of the file). Also confirm that depends_on is nested under the api service, not under db.

If curl returns "Connection refused", the API might still be starting. Wait a few seconds and try again.

help me debug why my Docker Compose healthcheck is not working and the API still crashes

✔️ Awesome, I've got everything!

Great. Double check you have saved compose.yaml and your stack is running without connection errors.

ⓧ I'd like to double check the full code

Your compose.yaml should contain these sections in order. Scroll up and confirm each block is present:

  • The api service with build, ports, environment, and depends_on (with db: condition: service_healthy).
  • The db service with image, environment, volumes, and healthcheck (with test, interval, timeout, retries, start_period).
  • The volumes section at the bottom declaring pgdata.

The race condition is gone. Your API now waits patiently for PostgreSQL to be ready before it starts. Next up, you will shrink that 400MB+ Docker image down to under 100MB using multi-stage builds.

Optimize the Image with Multi-Stage Builds

Your stack is healthy. The API waits for PostgreSQL, and every request succeeds. But look at the image you are shipping.

The naive task-tracker:naive image weighs over 400 MB because it bundles the full Python toolchain, compilers, and OS packages you only needed at install time. A multi-stage build discards all of that, copying only the runtime artifacts into a slim final image. The result is a dramatically smaller image with a reduced attack surface.

In this step, get ready to:
  • Rewrite the Dockerfile as a two-stage build using python:3.12-slim.
  • Build the optimized image and compare its size against the naive image.
  • Restart the Compose stack and confirm the app still works identically.
Rewrite the Dockerfile as a two-stage build

A multi-stage build uses multiple FROM statements in a single Dockerfile. The first stage installs dependencies. The second stage starts from a fresh base and copies only what the app needs to run.

  • Open Dockerfile in VS Code.
  • Delete the entire contents and replace them with this two-stage build:
# --- Stage 1: Builder ---
FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN python -m venv /app/venv && \
    /app/venv/bin/pip install --no-cache-dir -r requirements.txt

# --- Stage 2: Runtime ---
FROM python:3.12-slim AS runtime

WORKDIR /app

ENV PATH="/app/venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

COPY --from=builder /app/venv /app/venv
COPY app.py .

RUN useradd --create-home --shell /bin/bash appuser
USER appuser

EXPOSE 5000

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

What does this Dockerfile do?

Stage 1 (builder) creates a virtual environment and installs your Python packages into it. This stage has pip, wheel, and setuptools available for compilation.

Stage 2 (runtime) starts fresh from the same slim base. It copies only the pre-built venv from the builder, adds your app code, and creates a non-root user called appuser. Everything the builder used to install packages is gone. The final image contains nothing extra.

Why a non-root user?

Running as root inside a container means any vulnerability in your app grants full control of the container filesystem. The USER appuser directive drops privileges so the process runs with minimal permissions.

  • Save Dockerfile.
Build the optimized image and compare sizes
  • Build the new image with an optimized tag by running this command:
docker build -t task-tracker:optimized .

Before you check the result, predict: how much smaller do you think the optimized image will be compared to the 400 MB+ naive image?

  • Compare both images by running:
docker images task-tracker

You should see two rows. The naive tag is 400 MB+. The optimized tag is under 100 MB. That is a 4x+ reduction from a single Dockerfile rewrite.

Both images show the same size?

Make sure you saved the Dockerfile before building. If the file still has the old single-stage content, Docker will rebuild the same large image.

Verify your Dockerfile starts with FROM python:3.12-slim AS builder (not FROM python:3.12), then rebuild.

Still stuck? Help me debug why my multi-stage Docker build is still producing a large image.

Verify the app still works with Compose

The compose.yaml already points its build context at the current directory. Since you rewrote the Dockerfile in place, Compose will automatically use the new multi-stage build. No changes needed to compose.yaml.

  • Stop the running stack and restart it with a fresh build by running:
docker compose down && docker compose up --build

Watch the logs. You should see the API wait for the database healthcheck (just like before), then start successfully.

Before you test, predict: will the API respond the same way it did with the naive image, or will something break because of the non-root user?

  • Open a new terminal and test the API by running:
curl http://localhost:5000/tasks

You should see your tasks returned as a JSON array. The app works identically to before. The non-root user has no impact because the app only needs to listen on a port and connect to the database.

Seeing a permission denied error?

If the container exits with a permission error, check that COPY app.py . comes before the USER appuser line. Files copied before the USER switch are owned by root but world-readable. Files copied after would need explicit chown.

Need more help? Help me debug a permission denied error in my Docker container running as a non-root user.

✔️ Awesome, I've got everything!

Great. Double check you have saved your Dockerfile and that docker images task-tracker shows the optimized tag under 100 MB.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile you should have:

# --- Stage 1: Builder ---
FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN python -m venv /app/venv && \
    /app/venv/bin/pip install --no-cache-dir -r requirements.txt

# --- Stage 2: Runtime ---
FROM python:3.12-slim AS runtime

WORKDIR /app

ENV PATH="/app/venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

COPY --from=builder /app/venv /app/venv
COPY app.py .

RUN useradd --create-home --shell /bin/bash appuser
USER appuser

EXPOSE 5000

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

Your compose.yaml, app.py, requirements.txt, and .dockerignore are unchanged from the previous step.

Your image went from a bloated 400 MB+ to a lean sub-100 MB build. Next up, you will lock down the network so the database is invisible to the outside world.

Add Network Isolation

Your optimized image is lean and secure. But there is still a gap in your stack's architecture. Right now, all services in your Compose file share a single default network.

In production, a database should never be reachable from outside its application. Adding a dedicated internal network enforces this boundary so that only the API can talk to PostgreSQL.

In this step, get ready to:
  • Define two named networks in your Compose file to separate public and private traffic.
  • Assign services to the correct networks so the database is invisible to the host.
  • Verify that the API still works while the database port is completely unreachable from outside.
Define networks in your Compose file

By default, Docker Compose creates one shared network for all services. Any container can reach any other container on any port. That is convenient for development, but dangerous in production.

You will create two networks. A frontend network that is exposed to the host (so you can reach the API), and a backend network marked as internal (so the database is sealed off from the outside world).

  • In VS Code, open compose.yaml.
  • Find the api service. After the depends_on block, add these lines:
    networks:
      - frontend
      - backend
    restart: unless-stopped
  • Find the db service. After the healthcheck block, add these lines:
    networks:
      - backend
    restart: unless-stopped
  • At the bottom of the file, between the existing volumes: section and the end of file, add this new top-level networks section. Place it above volumes::
networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

What do these additions do?

  • networks: frontend on the api service connects it to the host-facing network. This is why ports: "5000:5000" still works.
  • networks: backend on both services creates a private channel between them. The api can reach db by hostname over this network.
  • internal: true on the backend network prevents any external connectivity. Containers on this network cannot reach the internet or be reached from the host.
  • restart: unless-stopped ensures both services restart automatically if they crash or if Docker restarts. This is standard production hardening.
  • Save compose.yaml.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

Your compose.yaml is longer than 30 lines now, so confirm these sections are present in order:

  • The api service with build, ports, environment, depends_on, networks: [frontend, backend], and restart: unless-stopped.
  • The db service with image, environment, volumes, healthcheck, networks: [backend], and restart: unless-stopped.
  • A top-level networks: section with frontend (driver: bridge) and backend (driver: bridge, internal: true).
  • A top-level volumes: section with pgdata.
  • No ports mapping on the db service. The database should have no host-exposed ports.
Restart the stack and verify isolation

The network configuration only takes effect when you recreate the containers. You need to tear down the existing stack and bring it back up with the new network definitions.

  • Stop and remove the existing containers by running:
docker compose down
  • Rebuild and start the stack in detached mode by running:
docker compose up -d --build

You should see Compose create the two new networks (task-tracker_frontend and task-tracker_backend) in the output before starting the containers.

Before you test, what do you expect to happen when you curl the API? Will it still work now that services are on separate named networks?

  • Test that the API still works by running:
curl http://localhost:5000/tasks

You should see an empty JSON array [] (or your previously created tasks). The API is reachable because it sits on the frontend network with its port exposed.

  • Now confirm that the API can reach the database internally by running:
docker compose exec api getent hosts db

You should see an IP address followed by db. This proves that the api container can resolve and reach db over the shared backend network.

  • Finally, confirm that port 5432 is NOT exposed to your host by running:
curl http://localhost:5432

You should see Connection refused. The database is completely invisible from outside the stack. Only the api container can reach it via the internal backend network.

Why does this matter in production?

Without network isolation, anyone who can reach your server could attempt to connect to PostgreSQL directly on port 5432. Even with a password, this exposes your database to brute-force attacks.

The internal: true flag makes the backend network a sealed environment. No inbound connections from the host, no outbound connections to the internet. The database exists only for the API.

API returning connection errors?

  • Make sure both api and db are on the backend network. If only the api is on frontend and db is on backend alone, they cannot communicate.
  • Check indentation in your YAML. The networks: key under each service must be indented at the service level (4 spaces), not the top level.
  • Run docker compose logs api to see the exact error. A could not translate host name "db" error means the services are on different networks with no overlap.

Still stuck? Help me debug why my API cannot reach the database after adding networks to compose.yaml

Your stack is now production-hardened. The image is optimized, the database waits for health before accepting connections, and the network ensures the database is invisible to the outside world. This is the exact pattern you will use when moving these containers into Kubernetes.

Secret mission

Add a Redis Cache with Compose Profiles

Production apps often have optional services like caching that should only run in certain environments. In this mission, you will add a Redis caching layer and use Compose profiles to toggle it on or off without maintaining separate compose files.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Docker containers (api, db, redis).
  • Docker images (task-tracker:naive, task-tracker:optimized, Compose-built image).
  • Docker volume (pgdata) storing PostgreSQL data.
  • Docker networks (frontend, backend).
  • Project folder (~/Desktop/task-tracker/) with all source files.

Keep everything running

No action needed. Choose this if you're still actively building on your task-tracker project.

  • Your containers remain running and your PostgreSQL data persists in the pgdata volume.
  • To stop the containers without removing anything, run:
docker compose stop

This frees up memory while keeping all data intact. Start again later with docker compose up.

Pause - I'll come back to this later

Shut down running processes and remove containers to free up system resources. Your images stay cached for a faster restart.

  • Stop and remove containers, networks, and the PostgreSQL data volume by running:
docker compose down -v

This removes the pgdata volume so your task data is gone. Your Docker images stay on disk for faster rebuilds next time.

  • When you come back, run docker compose up --build to recreate everything from scratch with fresh database tables.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes containers, volumes, networks, and locally-built images.

  • Remove all Compose-managed resources (containers, volumes, networks, and locally-built images) by running:
docker compose down -v --rmi local
  • Remove the manually tagged images you built earlier by running:
docker image rm task-tracker:naive task-tracker:optimized
  • Delete the project folder by running:
cd ~/Desktop
rm -rf task-tracker

After running these commands, all Docker resources and project files from this project are gone.

That's a Wrap!

That's a Wrap!

Nice work. You just built a production-patterned multi-container application with Flask, PostgreSQL, and Docker Compose, all running from a single command.

You've learned how to:

  • Orchestrate a Flask + PostgreSQL application with Docker Compose, using healthchecks and depends_on: condition: service_healthy to eliminate the database race condition.
  • Optimize a Docker image from 400MB+ to under 100MB using multi-stage builds, while applying production hardening with a non-root user, .dockerignore, and pinned image versions.
  • Isolate the database with internal networks so it is unreachable from outside the stack, enforcing a security boundary between frontend and backend services.
  • Secret Mission: Added a Redis caching layer toggled by Compose profiles, demonstrating environment-specific service composition.

Ready to quiz yourself?