Containerize a Python API with Docker

Build and orchestrate a Flask API with PostgreSQL using Docker Compose.

Introduction

30 Second Summary

Every app you use online runs inside a container. Learning to build and manage these containers is the most in-demand skill in DevOps today.

In this project, you will containerize a Python REST API and connect it to a PostgreSQL database using Docker Compose.

What You'll Build

A Task Tracker API built with Flask that runs alongside a PostgreSQL database, both orchestrated as containers by Docker Compose.

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

  • A working Flask REST API with endpoints to create, list, and delete tasks.
  • A production-quality Dockerfile with layer caching and a non-root user for security.
  • A Docker Compose setup orchestrating your API and database as a multi-container application with persistent storage.
  • Secret Mission: Optimize your image with a multi-stage build and push it to Docker Hub.

Are there any prerequisites?

You should have basic Python familiarity, VS Code installed, and Docker Desktop installed on your machine. No cloud accounts or paid tools are needed.

Verify Docker and Create the Project

Before you can containerize anything, you need a working project to put inside a container. In this step, you will confirm Docker is ready on your machine and then create the Flask API that you will containerize in the next step.

The app itself is a simple Task Tracker REST API. It exposes three endpoints for listing, creating, and deleting tasks. You will write all the code now so that the remaining steps focus entirely on Docker.

In this step, get ready to:
  • Verify Docker is installed and running.
  • Create the project folder and files.
  • Write the Flask application code and dependency files.
Verify your Docker installation

Docker Desktop bundles two tools you will use constantly: the Docker Engine (which builds and runs containers) and Docker Compose (which orchestrates multi-container apps). Let's confirm both are reachable from your terminal.

  • In your PowerShell terminal, check the Docker CLI version by running this command:
docker --version
  • Check the Docker Compose version by running this command:
docker compose version

Both commands should return a version number. The exact version does not matter as long as you see output.

✔️ I see version numbers for both

Docker is ready to go. Move on to creating your project folder.

ⓧ I see an error or 'not recognized'

Docker Desktop may not be running, or it has not finished starting up.

  • Look for the Docker whale icon in your system tray (bottom-right of your taskbar).
  • If the icon is missing, press the Windows key, type Docker Desktop, and press Enter to launch it.
  • Wait until the whale icon stops animating (this means Docker has finished starting).
  • Try both commands again.
Create the project folder and files
  • 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 this command:
New-Item app.py, requirements.txt, .dockerignore
  • Confirm the files exist by running:
ls

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

  • Open the task-tracker folder in VS Code by running:
code .

VS Code not opening?

If code . is not recognized, open VS Code manually. Click File, then Open Folder, and select the task-tracker folder on your Desktop.

help me add VS Code to my PATH so the code command works

Write the Flask application code

Your Task Tracker API uses Flask to handle HTTP requests and PostgreSQL to store tasks. You will write all the application code now so the remaining steps focus purely on Docker.

  • In VS Code, open app.py from the Explorer sidebar on the left.
  • Paste the following code into app.py. This is the database connection layer:
import os
import psycopg2
from flask import Flask, jsonify, request

app = Flask(__name__)


def get_db_connection():
    # Connect to PostgreSQL using environment variables (with local defaults)
    conn = psycopg2.connect(
        host=os.environ.get("DB_HOST", "localhost"),
        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():
    # Create the tasks table if it doesn't exist yet
    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 CURRENT_TIMESTAMP
        )
        """
    )
    conn.commit()
    cur.close()
    conn.close()

What does this code do?

  • get_db_connection() reads database credentials from environment variables. This lets Docker inject different values without changing the code.
  • init_db() creates a tasks table with an auto-incrementing ID, a title, and a timestamp. It only creates the table if it does not already exist.
  • Below the code you just pasted, add the REST API route handlers and the application entry point:

@app.route("/tasks", methods=["GET"])
def get_tasks():
    # Fetch all tasks from the database, newest first
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("SELECT id, title, created_at FROM tasks ORDER BY created_at DESC")
    tasks = [
        {"id": row[0], "title": row[1], "created_at": row[2].isoformat()}
        for row in cur.fetchall()
    ]
    cur.close()
    conn.close()
    return jsonify(tasks)


@app.route("/tasks", methods=["POST"])
def create_task():
    # Create a new task from the JSON request body
    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, created_at",
        (data["title"],),
    )
    row = cur.fetchone()
    conn.commit()
    cur.close()
    conn.close()
    return jsonify({"id": row[0], "title": row[1], "created_at": row[2].isoformat()}), 201


@app.route("/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
    # Delete a task by its ID
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("DELETE FROM tasks WHERE id = %s", (task_id,))
    conn.commit()
    deleted = cur.rowcount
    cur.close()
    conn.close()
    if deleted == 0:
        return jsonify({"error": "task not found"}), 404
    return jsonify({"message": "task deleted"}), 200


if __name__ == "__main__":
    # Initialize the database and start the Flask development server
    init_db()
    app.run(host="0.0.0.0", port=5000)

What do these routes do?

  • GET /tasks returns all tasks as a JSON list, ordered newest first.
  • POST /tasks creates a new task. It expects a JSON body with a title field.
  • DELETE /tasks/<id> removes a task by its ID. Returns a 404 if the task does not exist.
  • The if __name__ == "__main__": block initializes the database table and starts the Flask server on port 5000.
  • Save app.py by pressing Ctrl+S.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.py file. Make sure yours matches exactly:

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", "localhost"),
        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,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
        """
    )
    conn.commit()
    cur.close()
    conn.close()


@app.route("/tasks", methods=["GET"])
def get_tasks():
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("SELECT id, title, created_at FROM tasks ORDER BY created_at DESC")
    tasks = [
        {"id": row[0], "title": row[1], "created_at": row[2].isoformat()}
        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, created_at",
        (data["title"],),
    )
    row = cur.fetchone()
    conn.commit()
    cur.close()
    conn.close()
    return jsonify({"id": row[0], "title": row[1], "created_at": row[2].isoformat()}), 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", (task_id,))
    conn.commit()
    deleted = cur.rowcount
    cur.close()
    conn.close()
    if deleted == 0:
        return jsonify({"error": "task not found"}), 404
    return jsonify({"message": "task deleted"}), 200


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

Seeing red squiggles in VS Code?

VS Code may highlight psycopg2 or flask as unresolved imports. This is expected. You have not installed these packages locally because Docker will handle that inside the container.

help me understand why VS Code shows import errors for packages I haven't installed locally

Now write the dependency file that tells Docker which Python packages to install inside the container.

  • Open requirements.txt in VS Code and paste the following:
flask==3.1.3
psycopg2-binary==2.9.10

Why pin exact versions?

Pinning versions (with ==) ensures your container builds produce the same result every time. Without pins, a future package update could break your image without you changing any code.

  • Save requirements.txt.

Finally, write the .dockerignore file. This tells Docker which files to skip when copying your project into the image.

  • Open .dockerignore in VS Code and paste the following:
__pycache__
*.pyc
.git
.env
venv
.venv

What does .dockerignore do?

When Docker builds an image, it sends your entire project folder to the Docker daemon. The .dockerignore file excludes unnecessary files from that transfer.

This keeps your image smaller and prevents sensitive files (like .env) from ending up inside the container.

  • Save .dockerignore.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before continuing.

ⓧ I'd like to double check the full code

Here is requirements.txt:

flask==3.1.3
psycopg2-binary==2.9.10

And here is .dockerignore:

__pycache__
*.pyc
.git
.env
venv
.venv

Your project files are in place and Docker is confirmed working. Next up, you will write a Dockerfile that packages this Flask app into a container image.

Write the Dockerfile and Build Your Image

You've got your Flask API code, dependencies, and .dockerignore in place. Now it's time to teach Docker how to package everything into a container image.

A Dockerfile is a recipe that Docker follows, step by step, to build a container image. Each instruction in the file becomes a layer in the image, and Docker caches these layers to avoid repeating work on future builds.

In this step, get ready to:
  • Create a production-quality Dockerfile with layer caching and a non-root user.
  • Build your first Docker image.
  • Verify the image exists locally and check its size.
Create the Dockerfile

A Dockerfile tells Docker exactly what base software to start from, which files to include, which dependencies to install, and how to run your app. The order of instructions matters because Docker caches each layer individually.

  • In VS Code, click the new file icon in the Explorer sidebar.
  • Name the file Dockerfile (no extension, capital D).
  • Paste the following code into your new Dockerfile:
# Start from a lightweight Python image
FROM python:3.14-slim

# Set the working directory inside the container
WORKDIR /app

# Copy requirements first for layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY app.py .

# Document the port this container listens on
EXPOSE 5000

# Create a non-root user for security
RUN adduser --disabled-password --no-create-home appuser
USER appuser

# Set the default command to run the app
CMD ["python", "app.py"]

What does this Dockerfile do?

  • FROM python:3.14-slim starts from a minimal Python image. The slim variant strips out compilers and docs you don't need, keeping the image small.
  • WORKDIR /app creates and switches to the /app directory inside the container. All following commands run from here.
  • COPY requirements.txt . is copied before the app code on purpose. If your app.py changes but dependencies don't, Docker reuses the cached pip install layer instead of re-downloading packages.
  • RUN pip install --no-cache-dir -r requirements.txt installs Flask and psycopg2-binary without storing pip's cache files, which saves image space.
  • COPY app.py . copies your application code into the image.
  • EXPOSE 5000 documents which port your app listens on. It doesn't actually publish the port.
  • RUN adduser and USER appuser create and switch to a non-root user. This limits what an attacker can do if they compromise your container.
  • CMD ["python", "app.py"] sets the default command that runs when the container starts.

Why copy requirements.txt separately?

This is a layer caching strategy. Docker rebuilds a layer only when the files it depends on change.

By copying requirements.txt first and running pip install, Docker caches the installed packages. When you later change app.py, Docker skips the expensive install step and only re-copies your code. This makes rebuilds much faster.

  • Save Dockerfile.
  • Confirm the file appears in the Explorer sidebar alongside app.py, requirements.txt, and .dockerignore.

Don't see the Dockerfile in Explorer?

  • Make sure you saved the file. Unsaved files show a dot on their tab in VS Code.
  • Check the file name is exactly Dockerfile with a capital D and no file extension. Not Dockerfile.txt or dockerfile.
  • Confirm the file is saved inside the task-tracker folder, not a subfolder.

Why isn't my Dockerfile showing up in VS Code?

✔️ Awesome, I've got everything!

Great. Double check you've saved Dockerfile before moving on.

ⓧ I'd like to double check the full code

Here's the complete Dockerfile for comparison:

FROM python:3.14-slim

WORKDIR /app

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

COPY app.py .

EXPOSE 5000

RUN adduser --disabled-password --no-create-home appuser
USER appuser

CMD ["python", "app.py"]
Build your Docker image

With the Dockerfile ready, you can now ask Docker to build the image. The build command reads your Dockerfile and executes each instruction, creating a new layer for each step.

  • Build your image by running this command in your PowerShell terminal:
docker build -t task-tracker:1.0 .

What does this command do?

  • docker build tells Docker to read the Dockerfile and create an image.
  • -t task-tracker:1.0 tags the image with a human-readable name (task-tracker) and version (1.0). Without a tag, you'd only have a cryptic hash to identify your image.
  • . (the dot) is the build context. It tells Docker to look in the current directory for the Dockerfile and any files referenced by COPY instructions.

Watch the output as Docker processes each instruction. You'll see numbered steps like [1/5], [2/5], etc. Each one corresponds to a layer being built.

What is happening during the build?

Docker downloads the python:3.14-slim base image (only on the first build). Then it runs each instruction in order, creating a cached layer at each step.

The first build takes a bit longer because Docker has to download the base image and install packages. Future builds reuse cached layers and finish in seconds, as long as your requirements.txt hasn't changed.

  • Wait for the build to finish. You'll see naming to docker.io/library/task-tracker:1.0 in the output when it completes successfully.

Build failing with an error?

  • If you see a "no such file or directory" error, make sure your PowerShell terminal is inside the task-tracker folder. Run cd ~/Desktop/task-tracker to get there.
  • If you see a "Cannot connect to the Docker daemon" error, make sure Docker Desktop is running. Look for the whale icon in your system tray.
  • If you see a syntax error, double-check that your Dockerfile matches the code above exactly. A missing space or wrong capitalisation will break the build.

Help me fix my Docker build error.

Verify the image exists
  • Confirm your new image is stored locally by running:
docker images

You should see a table with columns REPOSITORY, TAG, IMAGE ID, CREATED, and SIZE. Look for the row where REPOSITORY is task-tracker and TAG is 1.0.

What's the SIZE column telling me?

The SIZE column shows how much disk space the image uses. Because you used python:3.14-slim instead of the full python:3.14 image, your image is significantly smaller.

Smaller images download faster, start faster, and have a smaller attack surface. The slim variant strips out compilers, documentation, and other tools you don't need in production.

Don't see task-tracker in the list?

  • Make sure the docker build command from the previous substep finished without errors. Scroll up in your terminal to check for red error text.
  • If you see <none> in the REPOSITORY column, the build succeeded but the tag wasn't applied. Rebuild with the full command: docker build -t task-tracker:1.0 ..

Why doesn't my image appear when I run docker images?

Your image is built and ready to go. Next up, you'll run it as a live container and test your API endpoints.

Run Your First Container

You have a Docker image ready to go. The next question is: what happens when you actually run it?

In this step, you will launch a container from your image, try to access the API, and discover firsthand why a single container is not enough. You will also learn the essential Docker CLI commands that every DevOps engineer uses daily to inspect, troubleshoot, and manage containers.

In this step, get ready to:
  • Run the container and test the API endpoint.
  • Explore container management commands.
  • Understand container isolation and why Compose is needed.
Start the container and test the API

Your image contains the Flask app, but right now it is just a blueprint sitting on disk. Running it creates a live, isolated process called a container.

  • Start the container in the background by running this command:
docker run -d -p 5000:5000 --name tracker task-tracker:1.0

What do these flags do?

  • -d runs the container in detached mode (in the background) so you get your terminal back.
  • -p 5000:5000 maps port 5000 on your machine to port 5000 inside the container.
  • --name tracker gives the container a friendly name instead of a random one.
  • task-tracker:1.0 is the image you built in the previous step.

Docker prints a long container ID. That confirms it started.

  • Try to access the API by running:
curl http://localhost:5000/tasks

You will get a connection error instead of a response. The app crashed because it tried to connect to a PostgreSQL database that does not exist inside this container.

  • Check what happened by viewing the container logs:
docker logs tracker

You should see an error mentioning a failed connection to the database. This is expected.

Why did the container crash?

Your app.py calls init_db() on startup, which tries to connect to PostgreSQL on localhost inside the container. There is no database process running in there.

Each container is an isolated environment. The container only has what its image provides (Python and your Flask code). It knows nothing about other services. This is the core problem that Docker Compose solves in the next step.

Command not recognized?

  • If curl is not recognized in PowerShell, open http://localhost:5000/tasks in your browser instead. You will see the same connection failure.
  • If you get a "port already in use" error, make sure no other process is using port 5000. Run docker ps to check for existing containers.

help me figure out why my Docker container crashed on startup

Explore container management commands

Even though the container crashed, it still exists on disk in a stopped state. Docker keeps it around until you explicitly remove it. Let's walk through the commands you will use daily to manage containers.

  • List running containers by running:
docker ps

This will show an empty table because the container already exited. To see all containers (running and stopped), add the -a flag.

  • List all containers including stopped ones by running:
docker ps -a

You should see your tracker container with a status of Exited.

What is docker exec?

The command docker exec -it tracker sh opens a shell session inside a running container. It is like SSH-ing into a server to poke around the filesystem.

This only works when the container is actively running. Since your container has already exited, you would see an error about the container not running if you tried it now. You will use docker exec in the next step when Docker Compose keeps the container alive.

  • Remove the stopped container by running:
docker rm tracker
  • Confirm it is gone by running:
docker ps -a

The tracker container should no longer appear in the list.

What do these commands do?

  • docker ps lists running containers. Add -a to see stopped ones too.
  • docker logs tracker shows everything the container printed to stdout and stderr.
  • docker exec -it tracker sh opens an interactive shell inside a running container.
  • docker stop tracker gracefully stops a running container.
  • docker rm tracker deletes a stopped container from disk.

Container name already in use?

  • If a future docker run complains that the name tracker is already in use, it means you forgot to remove the old one. Run docker rm tracker first, then try again.

help me understand Docker container lifecycle commands

Understand container isolation

Let's step back and understand what you just experienced.

Why did this fail?

The container ran in complete isolation. It had port 5000 mapped from the container to your host machine, but inside, there was no PostgreSQL server to connect to.

When you removed the container with docker rm, everything inside it disappeared. If the app had managed to write any data, that data would be gone too. This is the ephemeral nature of containers.

To solve both problems (connecting services together and keeping data across restarts), you need two things: a Docker network for service communication, and a volume for persistent storage. Docker Compose handles both automatically.

Your image still exists locally (you can verify with docker images), but the container is gone. In the next step, you will use Docker Compose to spin up both the API and a PostgreSQL database together so they can talk to each other.

Compose a Multi-Container App

In the previous step, you ran a single container and saw it respond to requests. But when you stopped and removed that container, everything vanished. Real applications need persistent storage and multiple services working together.

Docker Compose solves this by letting you define your entire application stack in a single file. In this step, you will wire up your Flask API to a PostgreSQL database, launch both containers together, and prove that your data survives restarts.

In this step, get ready to:
  • Create a compose.yaml file defining the API and database services.
  • Launch and test the multi-container app.
  • Explore Compose lifecycle commands for logs, shutdown, and restart.
Create the compose.yaml file

A Compose file declares every service your app needs, how they connect, and where they store data. Instead of running long docker run commands with many flags, you describe the desired state in YAML and let Compose handle the rest.

  • In VS Code, create a new file called compose.yaml in the task-tracker folder (the same level as your Dockerfile).
  • Paste in the following code:
services:
  api:
    build: .
    ports:
      - "5000:5000"
    environment:
      - DB_HOST=db
      - DB_NAME=taskdb
      - DB_USER=taskuser
      - DB_PASSWORD=taskpass
    depends_on:
      - db

  db:
    image: postgres:17-alpine
    environment:
      - POSTGRES_USER=taskuser
      - POSTGRES_PASSWORD=taskpass
      - 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. The depends_on key tells Compose to start the database before the API.
  • db service: pulls the official postgres:17-alpine image and sets the database name, user, and password. The pgdata volume stores database files at /var/lib/postgresql/data so data survives container restarts.
  • volumes section: declares the named volume pgdata at the top level so Docker manages its lifecycle independently from containers.

Compose also creates a default Docker network for the app. Both services join this network automatically, which is how the api container can reach the database using the hostname db (the service name).

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

You should see compose.yaml listed in the output.

Don't see compose.yaml?

  • Make sure you saved the file in the task-tracker folder, not inside a subfolder.
  • Check the filename is exactly compose.yaml with no extra extension like .txt.

Why isn't my compose.yaml showing up?

✔️ 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

services:
  api:
    build: .
    ports:
      - "5000:5000"
    environment:
      - DB_HOST=db
      - DB_NAME=taskdb
      - DB_USER=taskuser
      - DB_PASSWORD=taskpass
    depends_on:
      - db

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

volumes:
  pgdata:
Launch and test the multi-container app

With your Compose file saved, a single command will build the API image, pull the PostgreSQL image, create the network and volume, and start both containers.

  • Start both services in detached mode by running:
docker compose up -d

What does this command do?

docker compose up reads your compose.yaml, builds any images that need building, pulls any images that need pulling, creates a network, and starts the containers. The -d flag runs everything in the background so you get your terminal back.

  • Verify both containers are running:
docker compose ps

You should see two services listed: api and db, both with a status of Up.

Seeing a container exit immediately?

  • If the api service exits, the database may not be ready yet. Run docker compose down then docker compose up -d again. Compose will retry the connection.
  • Check the logs with docker compose logs api to see the specific error message.
  • Make sure Docker Desktop is running and has not paused.

Help me debug why my container exited.

Now test the API endpoints to confirm the app connects to PostgreSQL.

  • Fetch the task list (it will be empty) by running:
curl.exe http://localhost:5000/tasks

You should see an empty JSON array: [].

  • Create a new task by running:
curl.exe -X POST http://localhost:5000/tasks -H "Content-Type: application/json" -d "{\"title\": \"Buy groceries\"}"

You should see a JSON response with the new task's id, title, and created_at fields.

  • Fetch the task list again to confirm the task was stored:
curl.exe http://localhost:5000/tasks

You should now see your Buy groceries task in the response. The data made it from your API container, through the Docker network, into the PostgreSQL container, and back.

Prefer the browser?

You can also open http://localhost:5000/tasks in your browser to test GET requests. For POST and DELETE, stick with curl.exe in PowerShell.

Explore Compose lifecycle commands

Knowing how to inspect, stop, and remove your services is just as important as starting them. These commands are the ones DevOps engineers use daily to manage containerized applications.

  • View the combined logs from both services by running:
docker compose logs

You should see interleaved output from both api and db, colour-coded by service name.

  • Stop and remove the containers (but keep the volume) by running:
docker compose down

What does docker compose down do?

docker compose down stops all running containers and removes them along with the default network. Named volumes are preserved by default, so your database data stays intact.

  • Bring the app back up and confirm your task survived the restart:
docker compose up -d
  • Fetch the tasks again:
curl.exe http://localhost:5000/tasks

Your Buy groceries task is still there. The named volume kept your PostgreSQL data safe across the full stop-and-start cycle.

What about docker compose down -v?

Adding the -v flag removes named volumes too. This is a full reset.

  • docker compose down removes containers and network. Data persists.
  • docker compose down -v removes containers, network, and volumes. Data is gone.

Use -v when you want a truly clean slate.

  • Try the full reset to see the difference:
docker compose down -v
  • Bring it back up one more time:
docker compose up -d
  • Check the task list:
curl.exe http://localhost:5000/tasks

This time you get an empty list []. The volume was deleted, so PostgreSQL started with a fresh database.

Still seeing old tasks after down -v?

  • Make sure you ran docker compose down -v (with the -v flag), not just docker compose down.
  • Confirm the volume was removed by running docker volume ls and checking that pgdata is not listed.

Why is my data still there after docker compose down -v?

You now have a complete multi-container application running locally. You can start it, stop it, check its logs, and control whether data persists or gets wiped. These are the exact Compose commands you will use in every Docker-based project going forward.

Secret mission

Optimize Your Image and Push to Docker Hub

Your image works, but can you make it smaller and share it with the world? In this bonus challenge, you'll refactor your Dockerfile with a multi-stage build pattern and publish the result to Docker Hub.

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 via Docker, so there are no ongoing cloud costs.

Resources you used:

  • Docker containers (api and db) running locally via Docker Compose.
  • A named Docker volume (pgdata) storing PostgreSQL data.
  • Docker image task-tracker:1.0 stored locally.
  • A Docker Hub repository (if you completed the Secret Mission).
  • Project files in the task-tracker folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your containers.

  • Your containers continue running in the background via Docker Desktop.
  • They use minimal local resources (CPU and memory).
  • You can test the API anytime at http://localhost:5000/tasks.

Pause - I'll come back to this later

Stop running containers to free up memory, but keep everything intact so you can resume later.

  • Stop your containers by running:
docker compose stop
  • Your containers, volumes, and images all remain on disk.
  • When you are ready to resume, navigate back to your task-tracker folder and run:
docker compose start
  • Your data persists in the pgdata volume, so any tasks you created will still be there.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Stop and remove all containers, networks, and volumes by running:
docker compose down -v
  • Remove the local Docker image by running:
docker rmi task-tracker:1.0
  • If you completed the Secret Mission, also remove the Docker Hub-tagged image by running:
docker rmi your-username/task-tracker:1.0
  • Navigate up one level and delete the project folder by running:
cd ..
Remove-Item -Recurse -Force task-tracker
  • If you completed the Secret Mission and want to delete your Docker Hub repository, sign in to Docker Hub in your browser.
  • Select My Hub, then Repositories.
  • Select your task-tracker repository.
  • Select the Settings tab.
  • Select Delete repository, enter the repository name to confirm, then select Delete Repository Forever.

That's a Wrap!

That's a Wrap!

Nice work! You just containerized a Python REST API, connected it to a PostgreSQL database, and orchestrated both services with Docker Compose.

You've learned how to:

  • Containerized a Flask REST API with a production-quality Dockerfile using layer caching, a slim base image, and a non-root user for security.
  • Orchestrated a multi-container app (API + PostgreSQL) with Docker Compose, using named volumes for persistent data and automatic Docker networks for service-to-service communication.
  • Managed containers with core Docker CLI commands: build, run, ps, logs, exec, stop, and rm.
  • Secret Mission: Optimized your image with a multi-stage build and published it to Docker Hub.

Ready to quiz yourself?