Containerize a Python API with Docker

Build a Flask task tracker API with PostgreSQL running in Docker containers.

Introduction

30 Second Summary

Every developer has hit the "it works on my machine" problem. You build something locally, hand it to someone else, and it breaks because their setup is slightly different. Docker solves this by packaging your app and everything it needs into a portable container that runs the same way everywhere.

In this project, you will build a Python task tracker API connected to a PostgreSQL database, then containerize the entire application using Docker Compose. You will create, list, and delete tasks through HTTP requests to prove your containers are working end to end.

What You'll Build

A two-container application with a Flask REST API and a PostgreSQL database, orchestrated by a single Docker Compose file you can run with one command.

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

  • A Dockerfile that packages your Python app into a container image.
  • A compose.yaml file that defines and runs your multi-container application with one command.
  • A working REST API with four endpoints you can test from your terminal using curl.
  • Secret Mission: Add health checks and persistent volumes so your data survives container restarts.

Are there any prerequisites?

None. This project walks you through installing Docker Desktop and writing all the code from scratch. You do not need prior Docker experience or a cloud account.

Install Docker Desktop

Docker Desktop is the application that bundles the Docker engine, CLI, and Docker Compose into a single install for your Mac.

In this step, you will download Docker Desktop, verify it is running, and confirm your setup by running your first container.

In this step, get ready to:
  • Download and install Docker Desktop for macOS.
  • Verify Docker and Docker Compose are working.
  • Run your first container.
Download and install Docker Desktop

Docker Desktop provides the engine that builds and runs containers on your Mac. You need it installed before you can containerize anything.

Now open Docker Desktop for the first time.

  • Press Cmd+Space to open Spotlight search.
  • Type Docker and press Enter to open it.
  • Accept the license agreement when prompted.
  • Select Use recommended settings when asked about configuration.

What is Docker Desktop?

Docker Desktop is a GUI application that runs the Docker engine in the background on your Mac. It gives you everything you need to build images, run containers, and orchestrate multi-container apps with Docker Compose.

Docker Desktop is free for personal use, education, and small businesses.

Docker won't open?

If macOS blocks Docker from opening, go to System Settings then Privacy & Security and click Open Anyway next to the Docker message.

help me open Docker Desktop on my Mac

Verify Docker is running

With Docker Desktop open, you can verify everything installed correctly by checking the version from Terminal.

  • Check the Docker CLI version by running this command in Terminal:
docker --version

You should see a version number printed, something like Docker version 28.x.x.

  • Check that Docker Compose is available by running:
docker compose version

You should see a version number like Docker Compose version v2.x.x. Both commands printing without errors means Docker is ready.

✔️ I see version numbers for both commands

Docker and Docker Compose are installed and running. You are ready to move on.

ⓧ Command not found or Docker daemon not running

If you see a 'command not found' error, Docker Desktop may not have finished starting up yet.

  • Look for the Docker whale icon in your menu bar at the top of the screen. If it is animating, Docker is still starting.
  • Wait until the whale icon stops animating, then try the commands again.
  • If there is no whale icon, open Docker Desktop again from Spotlight (Cmd+Space, type Docker).

help me fix Docker not being recognized in my terminal

Run your first container

The best way to confirm Docker can pull images and run containers is to run the official hello-world test image.

  • Run your first container by entering this command:
docker run --rm hello-world

You should see a "Hello from Docker!" message confirming your installation is successful. The --rm flag automatically removes the container after it finishes.

What just happened?

Docker pulled the hello-world image from Docker Hub (a public registry of container images), created a container from that image, ran it, and printed the message. This is the core Docker workflow: pull an image, run a container, see the output.

Seeing a permission error or timeout?

If you see a "permission denied" or "Cannot connect to the Docker daemon" error, Docker Desktop is not fully running yet. Wait for the whale icon in the menu bar to stop animating, then retry the command.

If the command hangs or times out, check your internet connection. Docker needs to download the hello-world image the first time you run it.

help me fix docker run errors on my Mac

Docker is installed and working. Next up, you will write a Python Flask API that you will later containerize with Docker.

Write Your Flask API

Docker is installed and ready to go. Now you need an application to containerize.

In this step, you'll write a Python Flask API that manages tasks. It connects to a PostgreSQL database to store, retrieve, and delete tasks through HTTP endpoints.

In this step, get ready to:
  • Create the project folder and application files.
  • Write the Flask API application.
  • Pin the Python dependencies in a requirements file.
Create the project folder and files

Every containerized application starts with a project directory that holds the source code and configuration files. You'll create a folder on your Desktop so it's easy to find.

  • 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
  • Create the three files your project needs by running:
touch app.py requirements.txt Dockerfile
  • Confirm the files exist by running:
ls

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

Don't see all three files?

Make sure you ran cd task-tracker before running touch. If the folder doesn't exist, re-run mkdir task-tracker first.

help me troubleshoot missing files in my project folder

Write the Flask application

Your Flask API connects to PostgreSQL, creates a tasks table on startup, and exposes four endpoints for managing tasks. You'll build this file in three sections.

  • Open app.py in your text editor by running:
nano app.py
  • Paste the following code into the file to set up the imports, Flask app, and database helper functions:
import os
import psycopg2
from flask import Flask, jsonify, request

app = Flask(__name__)


def get_db_connection():
    conn = psycopg2.connect(
        host=os.environ.get("DB_HOST", "db"),
        database=os.environ.get("DB_NAME", "taskdb"),
        user=os.environ.get("DB_USER", "taskuser"),
        password=os.environ.get("DB_PASSWORD", "taskpass"),
    )
    return conn


def init_db():
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute(
        """
        CREATE TABLE IF NOT EXISTS tasks (
            id SERIAL PRIMARY KEY,
            title TEXT NOT NULL,
            done BOOLEAN DEFAULT FALSE
        )
        """
    )
    conn.commit()
    cur.close()
    conn.close()

What does this code do?

  • get_db_connection() reads database credentials from environment variables. The fallback values (like "db" and "taskdb") will match the Docker Compose configuration you write in Step 4.
  • init_db() connects to PostgreSQL and creates a tasks table if one doesn't already exist. This means your database sets itself up automatically every time the app starts.

Why environment variables for credentials?

Hardcoding database passwords in source code is a security risk. Environment variables let you inject credentials at runtime without changing the code. Docker Compose makes this easy by passing variables directly to each container.

  • Below the init_db function, add the two read-only API endpoints:


@app.route("/")
def health():
    return jsonify({"status": "healthy", "service": "task-tracker"})


@app.route("/tasks", methods=["GET"])
def get_tasks():
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("SELECT id, title, done FROM tasks ORDER BY id")
    tasks = [
        {"id": row[0], "title": row[1], "done": row[2]} for row in cur.fetchall()
    ]
    cur.close()
    conn.close()
    return jsonify(tasks)

What do these endpoints do?

  • GET / is a health check. It returns a simple JSON response confirming the API is running.
  • GET /tasks queries the database for all tasks and returns them as a JSON list. The ORDER BY id keeps results in creation order.
  • Below the get_tasks function, add the write endpoints and the startup call:


@app.route("/tasks", methods=["POST"])
def create_task():
    data = request.get_json()
    if not data or "title" not in data:
        return jsonify({"error": "title is required"}), 400
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute(
        "INSERT INTO tasks (title) VALUES (%s) RETURNING id, title, done",
        (data["title"],),
    )
    row = cur.fetchone()
    conn.commit()
    cur.close()
    conn.close()
    return jsonify({"id": row[0], "title": row[1], "done": row[2]}), 201


@app.route("/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("DELETE FROM tasks WHERE id = %s RETURNING id", (task_id,))
    deleted = cur.fetchone()
    conn.commit()
    cur.close()
    conn.close()
    if deleted is None:
        return jsonify({"error": "task not found"}), 404
    return jsonify({"deleted": deleted[0]})


init_db()

What do these endpoints do?

  • POST /tasks accepts JSON with a title field and inserts a new task into the database. It returns the created task with a 201 status code.
  • DELETE /tasks/<id> removes a task by its ID and returns 404 if the task doesn't exist.
  • init_db() at the bottom of the file runs the database setup as soon as the app starts. This ensures the tasks table is always ready.
  • Save and close the file by pressing **Ctrl+O**, then **Enter** to confirm, then **Ctrl+X** to exit nano.

You won't see any output yet. This code needs a running PostgreSQL database to work, which you'll provide through Docker Compose in Step 4.

Seeing a paste error or jumbled code?

If the code looks wrong after pasting, press **Ctrl+X** (don't save), then reopen with nano app.py and try pasting again. Make sure your terminal isn't wrapping lines unexpectedly.

help me fix my app.py file

✔️ Awesome, I've got everything!

Great. Double check you saved the file before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.py file for comparison:

import os
import psycopg2
from flask import Flask, jsonify, request

app = Flask(__name__)


def get_db_connection():
    conn = psycopg2.connect(
        host=os.environ.get("DB_HOST", "db"),
        database=os.environ.get("DB_NAME", "taskdb"),
        user=os.environ.get("DB_USER", "taskuser"),
        password=os.environ.get("DB_PASSWORD", "taskpass"),
    )
    return conn


def init_db():
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute(
        """
        CREATE TABLE IF NOT EXISTS tasks (
            id SERIAL PRIMARY KEY,
            title TEXT NOT NULL,
            done BOOLEAN DEFAULT FALSE
        )
        """
    )
    conn.commit()
    cur.close()
    conn.close()


@app.route("/")
def health():
    return jsonify({"status": "healthy", "service": "task-tracker"})


@app.route("/tasks", methods=["GET"])
def get_tasks():
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("SELECT id, title, done FROM tasks ORDER BY id")
    tasks = [
        {"id": row[0], "title": row[1], "done": row[2]} for row in cur.fetchall()
    ]
    cur.close()
    conn.close()
    return jsonify(tasks)


@app.route("/tasks", methods=["POST"])
def create_task():
    data = request.get_json()
    if not data or "title" not in data:
        return jsonify({"error": "title is required"}), 400
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute(
        "INSERT INTO tasks (title) VALUES (%s) RETURNING id, title, done",
        (data["title"],),
    )
    row = cur.fetchone()
    conn.commit()
    cur.close()
    conn.close()
    return jsonify({"id": row[0], "title": row[1], "done": row[2]}), 201


@app.route("/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("DELETE FROM tasks WHERE id = %s RETURNING id", (task_id,))
    deleted = cur.fetchone()
    conn.commit()
    cur.close()
    conn.close()
    if deleted is None:
        return jsonify({"error": "task not found"}), 404
    return jsonify({"deleted": deleted[0]})


init_db()
Write the requirements file

Python projects use a requirements.txt file to pin exact dependency versions. This ensures every environment installs the same packages.

  • Open requirements.txt in your text editor by running:
nano requirements.txt
  • Paste the following two dependencies:
flask==3.1.3
psycopg2-binary==2.9.12

What are these packages?

  • flask==3.1.3 is the web framework that powers your API endpoints.
  • psycopg2-binary==2.9.12 is the PostgreSQL adapter that lets Python talk to the database. The -binary variant comes pre-compiled so it works inside a slim Docker image without extra system libraries.
  • Save and close the file by pressing **Ctrl+O**, then **Enter**, then **Ctrl+X**.
  • Verify the file contents by running:
cat requirements.txt

You should see flask==3.1.3 and psycopg2-binary==2.9.12 printed in the terminal.

File looks empty or has extra characters?

Reopen with nano requirements.txt and make sure each package is on its own line with no extra spaces. The == pins the exact version number.

help me fix my requirements.txt

Your Flask application is written and your dependencies are pinned. Next up, you'll write a Dockerfile that packages this code into a container image Docker can run.

Containerize with a Dockerfile

Your Flask API and dependencies are ready to go. Now you need a way to package everything into a single, portable unit that runs the same on any machine.

A Dockerfile is the recipe that tells Docker exactly how to build a container image from your code. In this step, you will write that recipe and use it to build your first custom image.

In this step, get ready to:
  • Write a Dockerfile that packages your Python app.
  • Build a Docker image from that Dockerfile.
  • Understand why instruction order matters for build speed.
Write your Dockerfile

A Dockerfile is a plain text file with a set of instructions. Each instruction creates a layer in your image, building up from a base operating system to a fully runnable app.

  • Open a new file called Dockerfile in nano by running this command:
nano Dockerfile
  • Paste the following content into the editor:
FROM python:3.12-slim

WORKDIR /app

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

COPY app.py .

EXPOSE 5000

CMD ["flask", "run", "--host=0.0.0.0"]

What does this Dockerfile do?

  • FROM python:3.12-slim starts from a lightweight Python 3.12 base image. The slim variant keeps the image small by excluding tools you do not need.
  • WORKDIR /app sets the working directory inside the container. All following commands run from here.
  • COPY requirements.txt . and RUN pip install install your Python packages first, before copying app code. This takes advantage of Docker layer caching.
  • COPY app.py . adds your application code to the image.
  • EXPOSE 5000 documents which port your app listens on.
  • CMD ["flask", "run", "--host=0.0.0.0"] is the default command that runs when a container starts from this image. The --host=0.0.0.0 flag makes Flask accept connections from outside the container.
  • Save and exit nano by pressing **Ctrl+O**, then **Enter**, then **Ctrl+X**.
  • Confirm the file was created by running:
ls

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

Don't see the Dockerfile?

Make sure you pressed **Ctrl+O** (the letter O, not zero) to write the file before exiting with **Ctrl+X**. If the file is missing, run nano Dockerfile again and repeat the process.

Also confirm you are inside the task-tracker folder by running pwd. You should see a path ending in task-tracker.

ask me for help fixing my Dockerfile issue

Build the Docker image

With your Dockerfile written, you can now build an image from it. Building reads each instruction, executes it, and stacks the results into a final image you can run as a container.

  • Build your Docker image by running this command:
docker build -t task-tracker .

What does this command do?

  • docker build tells Docker to read a Dockerfile and build an image from it.
  • -t task-tracker tags (names) the resulting image so you can reference it easily later.
  • . (the dot) tells Docker to look for the Dockerfile in the current directory.

You will see Docker process each instruction. The output ends with a line that says FINISHED when the build completes successfully.

  • Verify your image was created by running:
docker images

You should see task-tracker listed in the output with its size and creation timestamp.

Build failed with an error?

Check that your Dockerfile has no typos. The filename must be exactly Dockerfile with a capital D and no file extension.

If you see a requirements.txt error, make sure that file exists in the same folder as your Dockerfile. Run ls to confirm all three files are present.

If Docker reports a connection error, confirm Docker Desktop is still running by checking for the whale icon in your menu bar.

help me fix my Docker build error

Understand Docker layer caching

You might have noticed that the Dockerfile copies requirements.txt and installs dependencies before copying app.py. This is not random. It is a deliberate optimization.

Why does instruction order matter?

Docker caches each layer (instruction result). If a layer has not changed since the last build, Docker reuses the cached version instead of re-running it.

Your dependencies in requirements.txt change rarely. Your application code in app.py changes frequently. By installing dependencies first, Docker can skip the slow pip install step on most rebuilds and only re-copy your updated app code.

This turns a 30-second rebuild into a 1-2 second rebuild. In a real project with dozens of dependencies, this saves significant time during development.

✔️ Awesome, I've got everything!

Great. Double check you have saved your Dockerfile and that docker images shows your task-tracker image.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile for comparison:

FROM python:3.12-slim

WORKDIR /app

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

COPY app.py .

EXPOSE 5000

CMD ["flask", "run", "--host=0.0.0.0"]

Your app is now packaged into a portable Docker image. Next up, you will use Docker Compose to spin up your app alongside a real PostgreSQL database and connect the two containers together.

Orchestrate with Docker Compose

You have a working Docker image for your Flask app. However, your app needs a PostgreSQL database to store tasks. Running two containers manually and wiring them together with network flags would be tedious and error-prone.

Docker Compose solves this. It lets you define your entire multi-container application in a single YAML file, then start everything with one command.

In this step, get ready to:
  • Create a compose.yaml file defining your two-service application.
  • Start the full application stack with a single command.
  • Verify both services are running and check their logs.
Create the compose.yaml file

A compose.yaml file declares all the services your application needs, how they connect to each other, and what configuration they require. Docker Compose reads this file and creates everything for you.

  • Create the compose.yaml file inside your task-tracker folder by running:
touch compose.yaml
  • Open compose.yaml in your text editor and paste the following content:
services:
  # Your Flask app, built from the local Dockerfile
  web:
    build: .
    ports:
      - "5000:5000"
    environment:
      - FLASK_APP=app.py
      - DB_HOST=db
      - DB_NAME=taskdb
      - DB_USER=taskuser
      - DB_PASSWORD=taskpass
    depends_on:
      - db

  # PostgreSQL database using the official Alpine image
  db:
    image: postgres:17-alpine
    environment:
      - POSTGRES_DB=taskdb
      - POSTGRES_USER=taskuser
      - POSTGRES_PASSWORD=taskpass
    volumes:
      - pgdata:/var/lib/postgresql/data

# Named volume to persist database data between restarts
volumes:
  pgdata:

What does this file do?

This file defines two services that form your application:

  • web builds your Flask app from the Dockerfile in the current directory. It maps port 5000 on your machine to port 5000 inside the container, and passes environment variables so the app knows how to connect to the database.
  • db pulls the official postgres:17-alpine image from Docker Hub. The POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD environment variables tell PostgreSQL what database and credentials to create on first start.
  • The volumes section at the bottom declares a named volume called pgdata. This stores your database data on disk so it survives container restarts.
  • depends_on: - db tells Docker Compose to start the database container before the web container.

Why Docker Compose instead of multiple docker run commands?

You could start each container separately with long docker run commands, specifying networks, ports, and environment variables each time. Docker Compose replaces all of that with a single declarative file.

The compose file also acts as documentation. Anyone reading it can instantly see what your application needs to run. This is the standard pattern for multi-container apps in professional teams.

  • Save compose.yaml.
  • Confirm the file exists by running:
ls

You should see compose.yaml listed alongside app.py, requirements.txt, and Dockerfile.

Don't see compose.yaml?

Make sure you ran touch compose.yaml inside the task-tracker folder, not a parent directory. Run pwd to check your current location.

Help me troubleshoot my compose.yaml file location.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete compose.yaml file for comparison:

services:
  web:
    build: .
    ports:
      - "5000:5000"
    environment:
      - FLASK_APP=app.py
      - DB_HOST=db
      - DB_NAME=taskdb
      - DB_USER=taskuser
      - DB_PASSWORD=taskpass
    depends_on:
      - db

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

volumes:
  pgdata:
Start the full application stack

With your compose file ready, a single command builds your image and starts both containers together on the same network.

  • Start both services by running:
docker compose up --build

What does this command do?

  • docker compose up reads your compose.yaml and creates containers for every service defined in it.
  • The --build flag forces Docker to rebuild the image from your Dockerfile before starting. This ensures any code changes are included.

Logs from both containers stream into your terminal. You will see the PostgreSQL database initialize first, then your Flask app start and connect to it.

What should I see?

The database logs appear first, showing PostgreSQL accepting connections. Then the web service logs show Flask starting on port 5000. Your terminal stays attached to the logs and keeps streaming output.

Leave this terminal running. You will open a new terminal tab in the next step to test your API.

Seeing a port conflict error?

If you see a message about port 5000 already being in use, another process is occupying that port. On macOS, AirPlay Receiver sometimes uses port 5000. Open System Settings, search for AirPlay Receiver, and turn it off. Then try the command again.

Help me fix a port conflict with Docker Compose.

Verify your services are running

Your logs are streaming, but you can also check the status of your containers from a separate terminal.

  • Open a new Terminal tab by pressing **Cmd+T**.
  • Navigate back to your project folder by running:
cd ~/Desktop/task-tracker
  • Check that both services are running by running:
docker compose ps

You should see two containers listed with a status of Up. One for the web service and one for the db service.

  • Check the Flask app logs specifically by running:
docker compose logs web

You should see Flask's startup message confirming it is running on http://0.0.0.0:5000. This means your API is ready to accept requests.

Seeing a connection error in the web logs?

If the web service logs show a connection refused error, the database may not have finished initializing before Flask tried to connect. Run docker compose down in your second terminal tab, then switch back to the first tab and run docker compose up --build again. The database usually starts faster on the second attempt because its data volume is already initialized.

Help me debug a database connection error in Docker Compose.

Your multi-container application is live. Next up, you will test your API by creating, listing, and deleting tasks through HTTP requests.

Test Your Running Application

Your multi-container stack is live. Both the Flask API and PostgreSQL database are running inside their own containers, connected by a shared network that Docker Compose created automatically.

Now it is time to prove everything works end-to-end. You will send real HTTP requests to your API, watch data flow into the database, and then shut the whole stack down cleanly.

In this step, get ready to:
  • Send HTTP requests to your containerized API using curl.
  • Create, list, and delete tasks through your API endpoints.
  • Shut down the stack and confirm the named volume persists.
Test the health check and create a task

Your current Terminal tab is occupied by the running Docker Compose logs. You need a separate tab to send requests to the API.

  • Open a new Terminal tab by pressing Cmd+T.
  • Test the health endpoint by running this command:
curl http://localhost:5000/

You should see a JSON response: {"service":"task-tracker","status":"healthy"}.

What does this response mean?

This response came from the health() function in your app.py. It proves that your Flask app is running inside its container and responding to HTTP requests on port 5000.

  • Create your first task by running this command:
curl -X POST -H "Content-Type: application/json" -d '{"title":"Learn Docker"}' http://localhost:5000/tasks

You should see a response like: {"done":false,"id":1,"title":"Learn Docker"}.

What just happened?

Your request hit the Flask container, which connected to the PostgreSQL container over the Docker network, inserted a row into the tasks table, and returned the new record. Data is flowing between two containers seamlessly.

Seeing 'Connection refused'?

Make sure Docker Compose is still running in your other Terminal tab. If you accidentally stopped it, switch back and run docker compose up again.

If you see a database connection error in the logs, the PostgreSQL container may not have finished starting. Wait a few seconds and try the curl command again.

Help me troubleshoot my curl request to the Docker container

List and delete tasks

You have created a task. Now test the remaining two endpoints to confirm the full CRUD flow works.

  • List all tasks by running this command:
curl http://localhost:5000/tasks

You should see a JSON array containing your task: [{"done":false,"id":1,"title":"Learn Docker"}].

  • Delete the task by running this command:
curl -X DELETE http://localhost:5000/tasks/1

You should see: {"deleted":1}. This confirms the task was removed from the database.

  • Run the list command one more time to confirm the task is gone:
curl http://localhost:5000/tasks

You should see an empty array: []. Your data round-tripped through both containers successfully.

Getting a 'task not found' error on delete?

If you already deleted the task or ran the POST command multiple times, the task ID might not be 1. Run curl http://localhost:5000/tasks first to see the actual IDs, then use the correct ID in your DELETE request.

Help me debug my DELETE request

Shut down the stack cleanly

Your tests are complete. Now you will stop both containers and remove the network that Docker Compose created. The named volume with your database data stays intact.

  • Switch back to the Terminal tab running Docker Compose logs.
  • Press Ctrl+C to stop the running containers.
  • Shut down and remove the containers and network by running this command:
docker compose down

You should see messages confirming that both containers are stopped and the network is removed.

What does docker compose down do?

This command stops all running containers defined in your compose.yaml and removes them along with the network. It does NOT remove named volumes by default. Your database data is safe.

  • Verify the named volume still exists by running:
docker volume ls

You should see task-tracker_pgdata listed in the output. This volume holds your PostgreSQL data and will persist even though the containers are gone.

Why does the volume persist?

Named volumes are designed to outlive containers. This is how production databases keep their data safe across deployments and restarts. If you wanted to delete the volume too, you would add the -v flag: docker compose down -v.

Don't see the volume listed?

If you accidentally ran docker compose down -v instead of docker compose down, the -v flag removes volumes too. That is fine for now. You can recreate the volume by running docker compose up --build again.

Help me understand Docker volumes and why mine is missing

You have built, run, and tested a complete multi-container application with Docker. Next up, tackle the Secret Mission to make your setup production-ready with health checks and data persistence verification.

Secret mission

Add Health Checks and Persistent Data Verification

Ready for more? Make your containers production-ready by adding health checks so Docker knows when each service is truly ready. Then prove your data survives container restarts thanks to the named volume you configured.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker Desktop, which is free for personal use. There are no cloud resources or ongoing costs. The only consideration is disk space used by Docker images and volumes.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker images (task-tracker, python:3.12-slim, postgres:17-alpine) stored locally on disk.
  • Docker volumes (pgdata) storing PostgreSQL data locally.
  • Project folder (~/Desktop/task-tracker) containing your app files.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment further with Docker commands.

  • Docker Desktop continues running in the background. Images and volumes persist on disk.
  • You can restart your stack at any time by opening Terminal, navigating to ~/Desktop/task-tracker, and running docker compose up.
  • Your project files remain in the task-tracker folder on your Desktop.

Pause - I'll come back to this later

Shut down running containers and quit Docker Desktop to free up memory. Your files and images stay on disk for when you return.

  • If your containers are still running, stop them by running this in Terminal:
  • Quit Docker Desktop by clicking the Docker icon in your menu bar and selecting Quit Docker Desktop.
  • Your images, volumes, and project files remain on disk. When you return, open Docker Desktop and run docker compose up from the task-tracker folder.
cd ~/Desktop/task-tracker
docker compose down

This stops the containers and removes the network, but keeps your images and volumes intact.

Delete - I don't want to use this again

Remove all project resources and reclaim disk space. This deletes your containers, images, volumes, and project files.

  • Stop containers and remove volumes by running:
cd ~/Desktop/task-tracker
docker compose down -v
  • Remove the project's Docker image by running:
docker image rm task-tracker
  • Remove all unused Docker data (images, containers, networks) by running:
docker system prune -a

Docker will ask you to confirm. Type y and press Enter.

  • Delete the project folder by running:
rm -rf ~/Desktop/task-tracker
  • Confirm the folder is gone by running:
ls ~/Desktop | grep task-tracker

If nothing is printed, the folder has been successfully removed.

Nice Work!

Nice Work!

You just containerized a full Python web application with a PostgreSQL database using Docker and Docker Compose. That is the exact workflow professional teams use to ship software consistently across environments.

Here's what you learned:

  • Wrote a Flask API from scratch and packaged it into a Docker image using a Dockerfile with layer caching for fast rebuilds.
  • Orchestrated a multi-container application (API + PostgreSQL) with Docker Compose, wiring services together through container networking and environment variables.
  • Managed the full Docker lifecycle by building images, starting services, testing endpoints with curl, and tearing everything down cleanly.
  • Secret Mission: Added health checks to both services and verified data persistence with Docker volumes across container restarts.

Ready to quiz yourself?