Containerize a Python Web App with Docker

Build a multi-container Flask and Redis app using Docker and Compose.

Introduction

30 Second Summary

Every developer has hit the moment where code runs perfectly on their laptop but breaks on someone else's. Containers solve this by packaging your application with everything it needs to run, making it portable and predictable.

In this project, you will containerize a Python web application using Docker. You will write a Dockerfile, build container images, connect multiple services with Docker Compose, and persist data with volumes.

What You'll Build

A Flask hit-counter web app backed by a Redis database, with both services running in Docker containers and orchestrated by Docker Compose.

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

  • A Dockerfile that packages the Flask app into a container image.
  • A compose.yaml file that defines two services (web + redis), a named volume, and automatic networking.
  • Hands-on experience with the full Docker workflow: building images, running containers, persisting data with volumes, and connecting services via networks.
  • Secret Mission: Add a reset endpoint and a health check to your Compose service.

Are there any prerequisites?

You should have basic Python familiarity and a code editor installed (such as VS Code). No prior Docker experience is required.

Verify Docker and Set Up the Project

Before building anything, you need to confirm that Docker is ready on your machine. Docker Desktop includes the Docker engine, the CLI, and Docker Compose.

Once verified, you will create the project files for a Flask web app that talks to Redis. You will not install Flask or Redis locally because Docker handles that.

In this step, get ready to:
  • Confirm Docker Desktop is running and Compose is available.
  • Create the project directory on your Desktop.
  • Write the Flask app code and list its dependencies.
Check that Docker is ready

Docker Desktop must be running for any Docker commands to work. Let's confirm the CLI and Compose are both available.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check the Docker CLI version by running this command:
docker --version
  • Check that Docker Compose is available by running:
docker compose version

✔️ I see version numbers for both commands

Docker is ready. You should see output similar to Docker version 27.x.x and Docker Compose version v2.x.x. The exact numbers may differ on your machine.

  • Make sure Docker Desktop is running (look for the whale icon in your menu bar or system tray). You will need it running for the rest of this project.

ⓧ Command not found or connection error

This means Docker Desktop is not installed correctly or its CLI is not in your system PATH.

  • Open Docker Desktop manually. Press Cmd+Space (macOS) or the Windows key (Windows), type Docker Desktop, and press Enter.
  • Wait for Docker Desktop to finish starting (the whale icon stops animating).
  • Try docker --version again in a new terminal window.
  • If the command still fails, reinstall Docker Desktop from https://docs.docker.com/get-started/get-docker/.
Create the project directory

Every Docker project starts with a directory that holds your application code, dependencies file, and eventually a Dockerfile. Let's create that now.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir hit-counter
cd hit-counter
  • Create the two files your app needs by running:
touch app.py requirements.txt
  • Confirm the files exist by running:
ls

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

Don't see both files?

Make sure you ran the touch command from inside the hit-counter folder. Run pwd to check your current directory. It should end with hit-counter.

On Windows, if touch is not recognized, use New-Item app.py, requirements.txt in PowerShell instead.

Help me create my project files in the terminal.

Write the Flask app and its dependencies

Now you will write a minimal Flask app that connects to Redis and counts page visits. The app itself is small. The key detail is the host="redis" connection string, which only works inside Docker's network (you will set that up in a later step).

  • Open the hit-counter folder in your code editor.
  • Open app.py and paste the following code:
from flask import Flask
import redis

# Create a Flask application instance
app = Flask(__name__)
# Connect to Redis using the hostname "redis" (resolved by Docker's network)
cache = redis.Redis(host="redis", port=6379)


@app.route("/")
def hello():
    # Increment the "hits" key in Redis and return the new count
    count = cache.incr("hits")
    return f"Hello! This page has been visited {count} times.\n"


if __name__ == "__main__":
    # Listen on all interfaces so Docker can forward traffic to this container
    app.run(host="0.0.0.0", port=5000)

What does this code do?

This file defines a single-page web app using Flask. When someone visits the root URL (/), the app increments a counter stored in Redis and displays the total visit count.

The host="redis" part looks odd right now. It does not point to any server on your machine. When this app runs inside Docker Compose, Docker automatically creates a network where each service is reachable by its name. The Redis service will be named redis, so this hostname resolves correctly inside the container.

  • Save app.py.
  • Open requirements.txt and paste the following:
flask==3.1.3
redis==5.3.1

Why pin exact versions?

Pinning versions with == ensures everyone who builds this image gets identical dependencies. Without pins, a future pip install could pull a newer version with breaking changes.

The redis package here is the Python client library (redis-py), not the Redis server itself. The server will run in its own container later.

  • Save requirements.txt.

Why not install these packages locally?

The entire point of Docker is to avoid installing project dependencies on your host machine. These packages will be installed inside a container in the next step when you write your Dockerfile. Your local Python does not need Flask or Redis.

✔️ Awesome, I've got everything!

Great. Double check that both files are saved in your code editor.

ⓧ I'd like to double check the full code

Here is the complete code for each file. Make sure yours matches exactly.

from flask import Flask
import redis

app = Flask(__name__)
cache = redis.Redis(host="redis", port=6379)


@app.route("/")
def hello():
    count = cache.incr("hits")
    return f"Hello! This page has been visited {count} times.\n"


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
flask==3.1.3
redis==5.3.1

Your project files are ready. Next up, you will write a Dockerfile that packages this app into a container image.

Write Your First Dockerfile

Your project folder has a Flask app and its dependencies listed, but there's no Python or Flask installed on your machine to actually run them. Now it's time to package everything into a Docker image that can run anywhere.

A Dockerfile is the recipe that tells Docker how to build that image. You'll write one, build your first image, and discover firsthand why a single container can't run this app alone.

In this step, get ready to:
  • Write a Dockerfile that packages your Flask app into an image.
  • Build the Docker image and observe the layer-by-layer output.
  • Run the container and observe why it fails without Redis.
Create your Dockerfile

A Dockerfile is a plain text file that lists instructions for building a container image. Each instruction creates a layer, and Docker caches these layers so rebuilds are fast.

  • In your code editor, create a new file named Dockerfile in the same folder as app.py.
  • Add the following content to your Dockerfile:
# syntax=docker/dockerfile:1
FROM python:3.13-slim

WORKDIR /app

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

COPY . .

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

What does this Dockerfile do?

  • # syntax=docker/dockerfile:1 enables the latest Dockerfile syntax features.
  • FROM python:3.13-slim sets the base image to a lightweight Python 3.13 environment.
  • WORKDIR /app creates and switches to the /app directory inside the container.
  • COPY requirements.txt . copies just the dependency list first. This lets Docker cache the install layer separately from your code.
  • RUN pip install --no-cache-dir -r requirements.txt installs Flask and redis-py inside the image.
  • COPY . . copies the rest of your application code into the container.
  • CMD ["python", "app.py"] sets the default command that runs when the container starts.

Why copy requirements.txt separately?

Docker caches each layer. If you copy requirements.txt first and install dependencies in their own layer, Docker can skip that slow pip install step on future builds as long as your dependencies haven't changed.

Changing only your application code triggers a rebuild from the COPY . . step onward, which takes seconds instead of minutes.

  • Save Dockerfile.
  • You won't see a visible change yet. The build command in the next substep will verify your Dockerfile is correct.

File not named correctly?

Make sure the file is named exactly Dockerfile with a capital D and no file extension. Some editors may try to add .txt automatically.

My Dockerfile isn't being recognized by Docker. Help me troubleshoot.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file, then move on to building the image.

ⓧ I'd like to double check the full code

# syntax=docker/dockerfile:1
FROM python:3.13-slim

WORKDIR /app

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

COPY . .

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

Building an image reads your Dockerfile and executes each instruction layer by layer. The result is a portable, self-contained package of your app and all its dependencies.

  • Build your image by running this command in your terminal (make sure you're in the folder containing Dockerfile):
docker build -t hit-counter .

What does this command do?

  • docker build tells Docker to read the Dockerfile and create an image.
  • -t hit-counter tags (names) the resulting image so you can reference it easily later.
  • . (the dot) tells Docker to use the current directory as the build context, which is where it looks for files to copy.
  • Watch the terminal output. You should see numbered steps like [1/5], [2/5], etc., as Docker processes each instruction.
  • When the build finishes, look for the word FINISHED in the output. This confirms your image was built successfully.

Build failed?

  • If you see a "no such file or directory" error, make sure you're running the command from the folder that contains your Dockerfile.
  • If you see a pip install error, double check that requirements.txt exists in the same folder and contains the correct package names.
  • If Docker is not recognized, make sure Docker Desktop is running.

Help me debug my Docker build error.

Run the container and observe the error

Your image is built. Time to run it as a container and see what happens when it tries to connect to Redis without Redis actually running.

  • Start a container from your image by running this command:
docker run -p 8000:5000 hit-counter

What does this command do?

  • docker run creates and starts a new container from the hit-counter image.
  • -p 8000:5000 maps port 8000 on your machine to port 5000 inside the container. This is called port mapping.
  • You should see Flask start up in your terminal. Open http://localhost:8000 in your browser.
  • Check your terminal. You'll see an error traceback about failing to connect to Redis. The app tries to reach a host named redis on port 6379, but no such service exists inside this single container.

This error is expected

Your Flask app is trying to connect to a Redis server at the hostname redis. Inside a standalone container, there's no Redis running and no DNS entry for that name. This is exactly why you need multi-container orchestration.

In the next step, you'll use Docker Compose to spin up both the web app and a Redis container on the same network, so they can find each other by name.

  • Stop the container by pressing Ctrl+C in your terminal.

Container won't stop?

If Ctrl+C doesn't work, open a new terminal window and run:

docker ps

Find the container ID in the first column, then run:

docker stop <container-id>

Help me stop my running Docker container.

Your image builds perfectly, but it can't function alone. Next up, you'll use Docker Compose to launch both your web app and a Redis database with a single command.

Launch the Full Stack with Docker Compose

In the last step, you built a Docker image and ran it as a container. It failed because Redis wasn't running anywhere. That failure proved an important point: real applications depend on multiple services working together.

Docker Compose solves this by letting you define and run multi-container applications from a single file. In this step, you'll create a compose.yaml that starts both your Flask app and a Redis database with one command.

What is Docker Compose?

Docker Compose is a tool for defining multi-container applications in a single YAML file. Instead of running each container manually with separate docker run commands, you describe all your services, their connections, and their configuration in one place.

Then a single command brings everything up together. Compose also handles networking between containers automatically, so services can talk to each other by name.

In this step, get ready to:
  • Create a compose.yaml defining two services.
  • Launch both containers with a single command.
  • Visit the running app and watch the counter increment.
Create the Compose file

A Compose file declares every service your application needs, how to build or pull each one, and how they connect. You'll define two services: your Flask web app and a Redis database.

  • Create a new file named compose.yaml in your hit-counter project folder.
  • Add the following content to compose.yaml:
services:
  web:
    build: .
    ports:
      - "8000:5000"
    depends_on:
      - redis

  redis:
    image: redis:alpine

What does this file do?

  • services: is the top-level key where you define each container your app needs.
  • web: defines your Flask app. build: . tells Compose to build an image using the Dockerfile in the current directory.
  • ports: - "8000:5000" maps port 8000 on your machine to port 5000 inside the container (where Flask listens).
  • depends_on: ensures the Redis container starts before the web container.
  • redis: defines the database service. image: redis:alpine pulls a lightweight pre-built Redis image instead of building from a Dockerfile.
  • Save compose.yaml.
  • Confirm the file exists by running:
ls

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

Don't see compose.yaml in the list?

  • Make sure you created the file inside the hit-counter folder, not a parent directory.
  • Check that the filename is exactly compose.yaml with no extra extension like .txt.

Help me figure out why my compose.yaml file isn't appearing in my project directory.

Start both services with Docker Compose

Now for the payoff. One command will build your image, create both containers, wire up their networking, and start everything.

  • Launch the full stack by running this command:
docker compose up --build

You'll see Docker build the web image (just like before), pull the redis:alpine image, then start both containers. Terminal output shows lines prefixed with web-1 and redis-1 as each service logs its startup.

How do the containers find each other?

Docker Compose automatically creates a network for your project. Every service can reach other services using the service name as a hostname. That's why app.py uses host="redis" in the Redis connection. Compose makes that name resolve to the Redis container's IP address.

Seeing a port conflict error?

  • If something else is already using port 8000 on your machine, you'll see a "port is already allocated" error. Stop the other process or change "8000:5000" to "8001:5000" in compose.yaml and run the command again.
  • If you see a Docker daemon error, make sure Docker Desktop is still running.

Help me fix an error when running docker compose up --build for my hit-counter project.

Visit the app and stop the stack

Both containers are running. Time to see your multi-container app in action.

  • Open your browser and go to http://localhost:8000.
  • Refresh the page a few times. The counter increments with each visit.

You should see a message like "Hello! This page has been visited 3 times." with the number increasing on every refresh. Your Flask app is talking to Redis through the Docker network, counting every hit.

  • Return to your terminal and press Ctrl+C to stop the stack.

Compose gracefully shuts down both containers. You'll see "Stopped" messages for each service.

Browser showing "unable to connect"?

  • Check that both containers are still running in the terminal (you should see log output, not a command prompt).
  • Make sure you're using http://localhost:8000 and not port 5000. Port 5000 is inside the container. Port 8000 is what's exposed to your machine.
  • If you changed the port mapping earlier to avoid a conflict, use that port instead.

Help me figure out why I can't access my Docker Compose app in the browser.

✔️ Awesome, I've got everything!

Great. Double check you've saved compose.yaml and confirmed the app works in your browser.

ⓧ I'd like to double check the full code

services:
  web:
    build: .
    ports:
      - "8000:5000"
    depends_on:
      - redis

  redis:
    image: redis:alpine

Your multi-container app is alive. Two services, one command, automatic networking. Next up, you'll tackle the one thing missing: what happens to your data when containers stop.

Persist Data with Volumes

Your multi-container app is running and counting visits. But what happens to that counter when you stop and restart the stack? Right now, all of Redis's data lives inside the container's writable layer.

When the container is removed, that data disappears. In this step, you will prove the problem and then fix it with a Docker volume, a storage mechanism that lives outside the container lifecycle.

In this step, get ready to:
  • Observe data loss when containers restart without volumes.
  • Add a named volume and persistence command to your Compose file.
  • Verify the counter survives a full stop-and-restart cycle.
See what happens when containers restart

Before adding any persistence, you need to see the problem firsthand. You will start the stack, increment the counter, then tear the stack down and bring it back up.

  • Start your stack in detached mode by running this command:
docker compose up -d --build
  • Open http://localhost:8000 in your browser.
  • Refresh the page 4 or 5 times to push the counter above 1.

You should see the visit count climbing with each refresh.

  • Stop and remove the containers by running:
docker compose down
  • Bring the stack back up by running:
docker compose up -d
  • Refresh http://localhost:8000 in your browser.

The counter is back to 1. All the previous visits are gone.

Why did the counter reset?

When you run docker compose down, Docker removes the containers and their writable layers. Redis was storing the hit count in memory inside its container. No container means no data.

A Docker volume solves this by giving the container a storage location that persists independently of the container lifecycle.

Add a named volume to your Compose file

To persist Redis data, you need two changes. First, tell Redis to write its data to disk using an append-only file. Second, mount a named volume at the directory where Redis writes that file.

  • In your code editor, open compose.yaml.
  • Replace the entire file contents with the following:
services:
  web:
    build: .
    ports:
      - "8000:5000"
    depends_on:
      - redis

  redis:
    image: redis:alpine
    command: redis-server --appendonly yes
    volumes:
      - redis-data:/data

volumes:
  redis-data:

What does this code do?

The command: redis-server --appendonly yes line tells Redis to write every change to an append-only file on disk instead of keeping data only in memory.

The volumes: block under the redis service mounts a named volume called redis-data at the /data directory inside the container. This is where Redis writes its append-only file.

The top-level volumes: section declares the named volume so Docker manages it. When you run docker compose down, Docker removes the containers but keeps the volume intact.

  • Save compose.yaml.

YAML indentation errors?

YAML is sensitive to spaces. Make sure command: and volumes: under the redis service are indented with exactly 4 spaces, matching image: above them.

The top-level volumes: key must have zero indentation, at the same level as services:.

help me fix YAML indentation in my compose.yaml

✔️ Awesome, I've got everything!

Great. Make sure you have saved compose.yaml before continuing.

ⓧ I'd like to double check the full code

Here is the complete compose.yaml file:

services:
  web:
    build: .
    ports:
      - "8000:5000"
    depends_on:
      - redis

  redis:
    image: redis:alpine
    command: redis-server --appendonly yes
    volumes:
      - redis-data:/data

volumes:
  redis-data:
Verify data persists across restarts

Now you will repeat the same stop-and-start cycle from earlier. This time, the counter should survive.

  • Stop the current stack by running:
docker compose down
  • Bring the stack back up with the new volume configuration by running:
docker compose up -d --build
  • Open http://localhost:8000 and refresh a few times to increment the counter past 1.
  • Note the number you see (for example, 5).
  • Tear the stack down and bring it back up again by running:
docker compose down
docker compose up -d
  • Refresh http://localhost:8000 in your browser.

The counter picks up right where it left off. Your data survived the restart because Redis wrote it to the volume, and docker compose down leaves named volumes intact.

Counter still resetting to 1?

Check that command: redis-server --appendonly yes is present in your compose.yaml under the redis service. Without it, Redis keeps data in memory only and the volume stays empty.

Also confirm you are running docker compose down (without the --volumes flag). Adding --volumes explicitly deletes named volumes, which defeats the purpose.

help me debug why my Redis volume is not persisting data across restarts

Your data now survives container restarts. Next up, you will learn how to inspect, debug, and manage your running containers with helpful Docker Compose commands.

Explore and Manage Your Containers

Your hit counter now survives restarts thanks to the named volume. You have a production-worthy data persistence setup.

In a real workflow, you need to inspect what is happening inside your running containers. In this step, you will learn the essential Docker Compose commands for monitoring, debugging, and cleaning up your containerized application.

In this step, get ready to:
  • Inspect running containers with docker compose ps.
  • View logs and open a shell inside a running container.
  • Clean up all containers and volumes.
Inspect your running containers

Your stack is running in detached mode from the previous step. Let's see exactly what is running.

  • Check the status of your containers by running this command:
docker compose ps

You should see a table with two rows. One for the web service and one for the redis service. The table shows each container's name, status, and port mappings.

What does this output tell me?

The NAME column shows Docker's auto-generated container names. The STATUS column confirms both containers are running. The PORTS column shows that the web container maps port 8000 on your machine to port 5000 inside the container.

This is your go-to command for a quick health check of your entire stack.

View logs and explore the container filesystem

Logs let you see what your application is printing to the console. This is essential for debugging when something goes wrong.

  • View the web container's output by running:
docker compose logs web

You should see Flask's startup messages and any HTTP requests that have been served. Each line is prefixed with the container name.

What are container logs?

Container logs capture everything the application prints to standard output and standard error. They are the same messages you would see in your terminal if you ran Flask locally. Docker stores them for you so you can review them any time.

Now let's go a step further and open a live shell inside the running container.

  • Open a shell inside the web container by running:
docker compose exec web sh

Your terminal prompt changes to something like /app #. You are now inside the running container.

  • Run ls to see the files inside the container.

You should see app.py, requirements.txt, and the other files that were copied in during the Docker build.

  • Type exit and press Enter to leave the container shell and return to your normal terminal.

Why is this useful?

Shelling into a container is invaluable for debugging. You can check that files were copied correctly, verify environment variables, test network connectivity, or inspect running processes. It is like SSH-ing into a remote server.

Shell command not responding?

Make sure you typed sh (not bash). The python:3.13-slim image includes sh but not bash.

If you see an error about the container not running, confirm your stack is up with docker compose ps first.

Help me debug why I can't shell into my container.

Clean up all resources

When you are done working, you should stop your containers and remove the volume to free up disk space.

  • Stop all containers and remove the named volume by running:
docker compose down --volumes

You should see Docker stopping both containers and then removing the redis-data volume.

What does the --volumes flag do?

Running docker compose down alone stops containers and removes the network. Adding --volumes also deletes any named volumes defined in your compose.yaml. Without it, the redis-data volume would persist on disk even after the containers are gone.

  • Confirm everything is removed by running:
docker compose ps

You should see no containers listed. Your environment is completely clean.

Still seeing containers?

Make sure you are running the command from inside your project directory (the folder containing compose.yaml). Docker Compose scopes commands to the project defined in that file.

Help me figure out why docker compose down did not remove everything.

Secret mission

Add a Reset Endpoint and a Health Check

Your hit counter works, but what if you want to reset it? And how does Docker know your app is actually healthy and not just sitting there frozen? In this secret mission, you will add a /reset endpoint to your Flask app and configure Docker to actively monitor your container's health.

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

Resources you used:

  • Docker containers (web and redis services).
  • Docker image (hit-counter).
  • Named volume (redis-data).
  • Project folder (hit-counter on your Desktop).

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with Docker Compose.

  • Your containers continue running in the background.
  • The hit counter keeps its value across browser visits.
  • Docker Desktop stays running in your system tray.

Pause - I'll come back to this later

Stop the running containers to free up memory, but keep your project files and Docker image for later.

  • Open your terminal and navigate to your hit-counter project folder.
  • Stop and remove the containers by running:
docker compose down

This stops the containers but keeps your named volume, image, and project files intact. When you are ready to resume, run docker compose up -d --build from the same folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes containers, the named volume, and your project files.

  • Open your terminal and navigate to your hit-counter project folder.
  • Stop containers and remove the named volume by running:
docker compose down --volumes
  • Remove the built Docker image. Open Docker Desktop, navigate to the Images tab in the left sidebar, find hit-counter, and click the trash icon to delete it.
  • Delete your project folder by running:
cd ~/Desktop
rm -rf hit-counter

On Windows, use this command instead:

cd ~\Desktop
rmdir /s /q hit-counter

Your machine is now clean of all project resources.

Nice Work!

Nice Work!

You just containerized a full-stack Python web application with Docker. Your Flask hit-counter and Redis database are running as a multi-container stack, defined entirely in code and launchable with a single command.

Here's what you learned:

  • Built a Docker image from a Dockerfile, understanding base images, layers, and the build cache.
  • Used Docker Compose to orchestrate a multi-container app with a single command, connecting services through automatic networking.
  • Persisted data across container restarts using named volumes, and inspected running containers with docker compose ps, logs, and exec.
  • Secret Mission: Added a /reset endpoint and configured Docker health checks to monitor container liveness.

Ready to quiz yourself?