Containerize a Python Web App with Docker

Build a containerized Flask app with Redis using Docker Compose.

Introduction

30 Second Summary

Every team that ships software needs a way to guarantee that code runs the same on every machine. Docker solves this by packaging your app and all its dependencies into a single, portable container.

In this project, you will containerize a Python web app from scratch, connect it to a Redis cache, and orchestrate everything with Docker Compose.

What You'll Build

A page-visit counter web app that increments every time you refresh the browser, built with Flask and backed by Redis for persistent storage.

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

  • A Dockerfile that packages your Flask app into a container image you can run anywhere.
  • A compose.yaml that defines a multi-service stack (web app + Redis) with health checks and environment variable configuration.
  • A named volume that persists your counter data across container restarts.
  • Secret Mission: Add Compose Watch for live code reloading without rebuilding.

Are there any prerequisites?

You need Docker Desktop installed on your machine and VS Code as your code editor. No Python installation is required because Python runs inside the container.

Verify Docker and Create the Project

Before writing any code, you need to confirm that Docker is ready to go on your machine. Docker Desktop provides both the Docker CLI and Docker Compose. These two tools will power everything you build in this project.

In this step, you will verify both tools are available and create the project folder where all your files will live.

In this step, get ready to:
  • Verify that the Docker CLI and Docker Compose are available.
  • Create the project directory and open it in VS Code.
Open a terminal and verify Docker

Docker Desktop runs in the background and exposes the docker command in your terminal. You will run two quick commands to confirm everything is working.

  • 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.
  • Confirm the Docker CLI is available by running this command:
docker --version
  • Confirm Docker Compose is available by running this command:
docker compose version

Both commands should print a version number. The exact numbers do not matter as long as you see output without errors.

Seeing an error or no output?

If either command returns an error, Docker Desktop might not be running yet. Open Docker Desktop from your Start menu (Windows) or Applications folder (macOS) and wait until the whale icon in your system tray shows a steady green state.

Once Docker Desktop is fully started, close and reopen your terminal, then try both commands again.

help me troubleshoot why docker --version is not working

Create the project directory and open it in VS Code

All of your project files will live in a single folder called compose-demo. You will create this folder on your Desktop so it is easy to find later.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir compose-demo
cd compose-demo
  • Confirm the folder was created by running:
ls

The output should be empty because the folder is brand new. That is expected.

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

What does code . do?

The code command launches VS Code from the terminal. The dot (.) tells it to open the current folder as a workspace.

Trust the workspace

VS Code may show a dialog asking if you trust the authors of the files in this folder. Click Yes, I trust the authors to enable all editor features.

You should see COMPOSE-DEMO in the Explorer panel on the left side of VS Code. The folder is empty for now.

VS Code didn't open?

If the terminal says the code command is not found, you need to add VS Code to your system PATH. Open VS Code manually, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), then type and select Shell Command: Install 'code' command in PATH.

Close and reopen your terminal, then run code . again.

help me get the code command working in my terminal

Your environment is ready. Next up, you will write the Flask app and package it into a Docker container image.

Write the Flask App and Dockerfile

Docker is installed and your project folder is ready. Now you need something to containerize.

In this step, you'll write a Python web app that counts page visits, then create the Dockerfile that tells Docker how to package it into a container image. You'll also define which files Docker should ignore during the build.

In this step, get ready to:
  • Create a Flask web app that counts page visits using Redis.
  • Write a Dockerfile that packages the app into a container image.
  • Add a .dockerignore to exclude unnecessary files from the build.
Create the Flask application files

The app you're building is a page-visit counter. Every browser refresh increments a number stored in Redis (an in-memory database). Flask handles the web request, Redis stores the count.

  • In VS Code, click the new file icon at the top of the Explorer panel (it looks like a page with a plus sign).
  • Name the file app.py and press Enter.
  • Paste the following code into app.py:
import os
import redis
from flask import Flask

# Create the Flask app and connect to Redis
app = Flask(__name__)
cache = redis.Redis(
    host=os.getenv("REDIS_HOST", "redis"),
    port=int(os.getenv("REDIS_PORT", "6379")),
)


# Define the route that increments and displays the counter
@app.route("/")
def hello():
    count = cache.incr("hits")
    return f"Hello from Docker! I have been seen {count} time(s).\n"

What does this code do?

The app connects to a Redis server using the REDIS_HOST and REDIS_PORT environment variables. If those variables aren't set, it defaults to redis on port 6379.

The single route (/) calls cache.incr("hits") which increments the hits key in Redis by one and returns the new value. Every page refresh adds one to the counter.

  • Save app.py by pressing Ctrl+S.

Now the app needs its Python dependencies listed in a separate file.

  • Create another new file in the Explorer panel called requirements.txt.
  • Paste the following into requirements.txt:
flask
redis

Why no version numbers?

The Dockerfile uses python:3.12-alpine which constrains the Python version. For this project, letting pip resolve the latest compatible Flask and redis packages keeps the focus on Docker concepts rather than dependency management.

  • Save requirements.txt.

✔️ Awesome, I've got everything!

Great. Make sure both app.py and requirements.txt are saved.

ⓧ I'd like to double check the full code

Here is app.py:

import os
import redis
from flask import Flask

app = Flask(__name__)
cache = redis.Redis(
    host=os.getenv("REDIS_HOST", "redis"),
    port=int(os.getenv("REDIS_PORT", "6379")),
)


@app.route("/")
def hello():
    count = cache.incr("hits")
    return f"Hello from Docker! I have been seen {count} time(s).\n"

And requirements.txt:

flask
redis
Write the Dockerfile

A Dockerfile is a recipe that tells Docker how to build a container image. Each line is an instruction: start from a base image, install dependencies, copy your code, and define how to run the app.

  • Create a new file called Dockerfile in the Explorer panel (no file extension).
  • Paste the following into Dockerfile:
# syntax=docker/dockerfile:1
FROM python:3.12-alpine
WORKDIR /code
# Set Flask environment variables
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
# Install system dependencies needed for compiling Python packages
RUN apk add --no-cache gcc musl-dev linux-headers
# Install Python dependencies first (cached layer if requirements don't change)
COPY requirements.txt .
RUN pip install -r requirements.txt
# Copy the rest of the application code
COPY . .
EXPOSE 5000
CMD ["flask", "run", "--debug"]

What does each instruction do?

  • FROM python:3.12-alpine starts from a lightweight Python image based on Alpine Linux.
  • WORKDIR /code creates a /code folder inside the container and moves into it.
  • RUN apk add --no-cache gcc musl-dev linux-headers installs system libraries needed to compile some Python packages on Alpine.
  • COPY requirements.txt . is copied first so Docker can cache this layer. If your dependencies don't change, Docker skips reinstalling them on the next build.
  • EXPOSE 5000 documents that the app listens on port 5000.
  • CMD ["flask", "run", "--debug"] is the command that runs when the container starts.
  • Save Dockerfile.

VS Code adding an extension to Dockerfile?

If VS Code tries to save it as Dockerfile.txt or similar, rename the file to just Dockerfile with no extension. Docker requires this exact name.

Help me fix my Dockerfile name

✔️ Awesome, I've got everything!

Great. Make sure Dockerfile is saved with no file extension.

ⓧ I'd like to double check the full code

# syntax=docker/dockerfile:1
FROM python:3.12-alpine
WORKDIR /code
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
RUN apk add --no-cache gcc musl-dev linux-headers
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["flask", "run", "--debug"]
Add a .dockerignore file

A .dockerignore file works like .gitignore but for Docker builds. It tells Docker which files to skip when copying your project into the image.

  • Create a new file called .dockerignore in the Explorer panel.
  • Paste the following into .dockerignore:
.env
*.pyc
__pycache__
redis-data

Why exclude these files?

  • .env contains local configuration values that shouldn't be baked into the image.
  • *.pyc and __pycache__ are Python bytecode files that get regenerated inside the container anyway.
  • redis-data is a local data directory that would waste space in the image.
  • Save .dockerignore.
  • Confirm all four files exist by checking the Explorer panel. You should see app.py, requirements.txt, Dockerfile, and .dockerignore listed.

Don't see .dockerignore in the Explorer?

Files starting with a dot are sometimes hidden by default. In VS Code, they should always appear in the Explorer panel. If the file didn't save correctly, try creating it again and make sure the name starts with a dot.

Help me find my .dockerignore file

✔️ Awesome, I've got everything!

Great. Double check all four files are saved.

ⓧ I'd like to double check the full code

.env
*.pyc
__pycache__
redis-data

Your application code and Dockerfile are ready. Next up, you'll define the multi-service stack with Docker Compose and see your counter app running in the browser.

Define Services with Docker Compose

You have a working Dockerfile that can build your Flask app into a container image. But your app also needs a Redis server to store the visit count.

Running multiple containers by hand gets messy fast. Docker Compose solves this by letting you define your entire application stack in a single file. One command starts everything together.

In this step, get ready to:
  • Create a .env file and a compose.yaml that defines your web and Redis services.
  • Start the full application stack with one command and see the counter working.
  • Observe that container data is ephemeral by default.
Create the environment and Compose files

Docker Compose uses environment variables from a .env file to inject configuration into compose.yaml. This keeps sensitive values and port numbers out of your committed code.

  • In VS Code, create a new file called .env in the root of your compose-demo folder.
  • Add the following configuration:
APP_PORT=8000
REDIS_HOST=redis
REDIS_PORT=6379

What does this configuration do?

  • APP_PORT=8000 sets the port your browser will use to reach the web app.
  • REDIS_HOST=redis tells the Flask app where to find Redis. The hostname redis matches the service name you will define in compose.yaml. Docker Compose creates a network where services can reach each other by name.
  • REDIS_PORT=6379 is the default port Redis listens on.
  • Save .env.

Now for the main event. The compose.yaml file declares every service in your application stack and how they connect.

  • Create a new file called compose.yaml in the root of your compose-demo folder.
  • Add the following service definitions:
services:
  web:
    build: .
    ports:
      - "${APP_PORT}:5000"
    environment:
      - REDIS_HOST=${REDIS_HOST}
      - REDIS_PORT=${REDIS_PORT}

  redis:
    image: redis:alpine

What does this Compose file do?

This file defines two services that Docker Compose will run together:

  • web builds a container image from the Dockerfile in the current directory (build: .). It maps port 8000 on your machine to port 5000 inside the container. It also passes the Redis connection details as environment variables so app.py can read them.
  • redis pulls the official redis:alpine image from Docker Hub. No build step needed since it uses a pre-built image.
  • Save compose.yaml.
  • Confirm both new files exist by running this in your terminal:
ls

You should see .env and compose.yaml alongside your existing app.py, Dockerfile, requirements.txt, and .dockerignore.

Don't see .env in the listing?

Files starting with a dot are hidden by default on some systems. Run ls -a to show all files including hidden ones. If .env is missing, make sure you created it in the compose-demo folder and not inside a subfolder.

help me find my .env file in the compose-demo project

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is .env:

APP_PORT=8000
REDIS_HOST=redis
REDIS_PORT=6379

Here is compose.yaml:

services:
  web:
    build: .
    ports:
      - "${APP_PORT}:5000"
    environment:
      - REDIS_HOST=${REDIS_HOST}
      - REDIS_PORT=${REDIS_PORT}

  redis:
    image: redis:alpine
Start the application stack

With one command, Docker Compose will build the web image from your Dockerfile and pull the Redis image from Docker Hub. Then it starts both containers on a shared network.

  • Start the entire stack by running this command in your terminal:
docker compose up

Watch the terminal output. Docker builds the web image layer by layer, pulls redis:alpine, creates a network, and starts both containers. This takes a minute or two the first time.

  • Open your browser and go to http://localhost:8000.
  • Refresh the page several times.

Each refresh increments the counter. You should see a message like "Hello from Docker! I have been seen 3 time(s)." with the number going up.

Page won't load?

  • Check the terminal for error messages. If you see a connection refused error for Redis, make sure your .env file has REDIS_HOST=redis (not localhost).
  • Make sure Docker Desktop is still running. Check the system tray for the Docker whale icon.
  • Confirm you are visiting http://localhost:8000 (port 8000, not 5000). The APP_PORT variable maps 8000 on your machine to 5000 inside the container.

help me debug why my Docker Compose web app is not loading in the browser

Stop and restart to observe ephemeral data

Your counter is working. But what happens to that data when you stop the containers? Containers are ephemeral by default. Any data stored inside them disappears when they are removed.

  • In your terminal, press Ctrl+C to stop the running stack.
  • Remove the stopped containers by running:
docker compose down
  • Start the stack again by running:
docker compose up
  • Open http://localhost:8000 in your browser again.

The counter is back to 1. All your previous visits are gone because Redis stored the count inside the container, and docker compose down removed that container.

Why did the counter reset?

Containers are designed to be disposable. When you run docker compose down, Docker removes the containers and their internal storage. The next docker compose up creates brand new containers with a fresh state.

This is fine for development, but in production you want data to survive restarts. In the next step, you will fix this with a named volume.

Counter not resetting?

If your counter still shows the old number, you may have used Ctrl+C without running docker compose down afterwards. Ctrl+C stops the containers but does not remove them. Run docker compose down first, then docker compose up to see the reset.

help me understand why my Docker counter is not resetting after restart

You have proven that containers lose their data by default. In the next step, you will add health checks and a named volume to make the counter persist across restarts.

Add Health Checks and Persistent Storage

In the previous step, you saw the counter reset to 1 every time you restarted the stack. That's because container data is ephemeral by default. Anything stored inside a container disappears when it stops.

In this step, you'll solve two problems at once. First, you'll add a health check so that Redis is fully ready before the web app tries to connect. Second, you'll add a named volume to persist data across restarts.

In this step, get ready to:
  • Add a health check and startup dependency to your Compose services.
  • Add a named volume so your counter survives restarts.
  • Explore your running stack with inspection commands.
Add a health check and startup dependency

Right now, Docker Compose starts both services at the same time. If the web app boots faster than Redis, it tries to connect to a database that isn't ready yet. A health check tells Compose to wait until Redis responds to a ping before starting the web service.

  • In VS Code, open compose.yaml and replace the entire file with this updated version:
services:
  web:
    build: .
    ports:
      - "${APP_PORT}:5000"
    environment:
      - REDIS_HOST=${REDIS_HOST}
      - REDIS_PORT=${REDIS_PORT}
    # Wait for Redis to pass its health check before starting
    depends_on:
      redis:
        condition: service_healthy

  redis:
    image: redis:alpine
    # Verify Redis is accepting connections
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

What does this code do?

The healthcheck block on the redis service runs redis-cli ping every 5 seconds. If Redis responds with PONG, Compose marks it as healthy.

The depends_on block on the web service uses condition: service_healthy to hold the web container until Redis passes its health check. This guarantees the Flask app never tries to connect to an unready database.

  • Save compose.yaml.

You won't see a visible change yet. The next substep adds volume persistence and starts the stack so you can test everything together.

Add a named volume and verify persistence

A named volume is storage that lives outside the container. When a container is removed and recreated, the volume stays. This is how production databases keep their data safe across deployments.

  • In compose.yaml, replace the entire file again with this final version that adds volume configuration:
services:
  web:
    build: .
    ports:
      - "${APP_PORT}:5000"
    environment:
      - REDIS_HOST=${REDIS_HOST}
      - REDIS_PORT=${REDIS_PORT}
    # Wait for Redis to pass its health check before starting
    depends_on:
      redis:
        condition: service_healthy

  redis:
    image: redis:alpine
    # Mount a named volume to persist Redis data
    volumes:
      - redis-data:/data
    # Verify Redis is accepting connections
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

# Declare named volumes used by services
volumes:
  redis-data:

What does the volume configuration do?

The volumes: - redis-data:/data line under the redis service maps the named volume redis-data to the /data directory inside the container. Redis writes its data here by default.

The top-level volumes: section declares redis-data as a Docker-managed volume. Even if you remove the container, this volume stays on your machine until you explicitly delete it.

  • Save compose.yaml.
  • Start the stack in detached mode by running this command in your terminal:
docker compose up -d

What does the -d flag do?

The -d flag runs containers in detached mode. They run in the background so you get your terminal back. Without it, the terminal would stay attached to the container logs.

  • Open http://localhost:8000 in your browser.
  • Refresh the page several times to build up a count (try 5-10 refreshes).

Now let's prove the data persists. Stop and remove the containers, then start them again.

  • Stop and remove the containers by running:
docker compose down
  • Start the stack again by running:
docker compose up -d
  • Refresh http://localhost:8000 in your browser. The counter continues from where it left off.

Counter still resets to 1?

Make sure you saved compose.yaml before running docker compose up -d. Unsaved changes won't be picked up.

Check that the top-level volumes: section exists at the bottom of your file, at the same indentation level as services:. Without this declaration, Docker doesn't create the named volume.

Help me debug my named volume configuration.

Explore your running stack with inspection commands

Your stack is running in the background. Docker Compose gives you commands to peek inside without stopping anything.

  • Stream live logs from all services by running:
docker compose logs -f
  • You should see log output from both the web and redis services. Press Ctrl+C to stop following the logs.
  • Query the raw counter value stored in Redis by running:
docker compose exec redis redis-cli GET hits

What does this command do?

The docker compose exec command runs a one-off command inside a running container. Here you're opening a redis-cli session inside the redis container and asking it to GET the value stored at the key hits. The number returned matches your browser's counter.

  • You should see a number matching the current visit count displayed in your browser.
  • Finally, remove everything including volumes to reset all data by running:
docker compose down -v

What does the -v flag do here?

The -v flag removes named volumes along with containers. Without it, docker compose down only removes containers and networks, leaving volumes intact. Use -v when you want a clean slate.

Seeing 'no such service' or connection errors?

Make sure you're running these commands from inside the compose-demo folder. Docker Compose looks for compose.yaml in the current directory.

If you see a connection error, the stack might not be running. Run docker compose up -d first to start the services.

Help me troubleshoot my Docker Compose inspection commands.

✔️ Awesome, I've got everything!

Great. Double check you've saved compose.yaml with the final version including health checks and volumes.

ⓧ I'd like to double check the full code

Here is the complete compose.yaml after this step:

services:
  web:
    build: .
    ports:
      - "${APP_PORT}:5000"
    environment:
      - REDIS_HOST=${REDIS_HOST}
      - REDIS_PORT=${REDIS_PORT}
    depends_on:
      redis:
        condition: service_healthy

  redis:
    image: redis:alpine
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

volumes:
  redis-data:

Your containerized app now has production-grade health checks and persistent storage. Next up, you'll take on the Secret Mission and add live code reloading so you can develop without rebuilding the image.

Secret mission

Add Compose Watch for Live Reloading

Right now, every time you change your Python code, you have to rebuild the Docker image and restart the container. What if your changes appeared automatically, just like a local development server? Compose Watch makes this possible.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine using Docker. There are no cloud resources or paid services, so there are no ongoing costs.

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

Resources you used:

  • Docker images (built locally, no ongoing cost).
  • Docker named volume compose-demo_redis-data (stored locally).
  • Project folder compose-demo on your Desktop.

Keep everything running

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

  • Your containers, images, and volumes stay in place.
  • Run docker compose up -d from the compose-demo folder anytime to start the app again.
  • Docker Desktop continues to manage resources in the background.

Pause - I'll come back to this later

Stop and remove containers to free up system resources, but keep your images and volumes for a quick restart later.

  • Press Ctrl+C in the terminal where docker compose up --watch is running to stop the stack.
  • Run this command to remove the containers:
docker compose down
  • Your images and the redis-data volume remain intact. Next time you run docker compose up -d, the app starts instantly without rebuilding.

Delete - I don't want to use this again

Remove all project resources and reclaim disk space. This deletes containers, volumes, images, and the project folder.

  • Press Ctrl+C in the terminal where docker compose up --watch is running to stop the stack.
  • Run this command to remove containers and the named volume:
docker compose down -v
  • Run this command to remove all unused Docker images from your machine:
docker image prune -a
  • Docker will ask you to confirm. Type y and press Enter.
  • Delete the project folder from your Desktop. Run the appropriate command for your system:

macOS

cd ~/Desktop
rm -rf compose-demo

Windows

cd ~\Desktop
Remove-Item -Recurse -Force compose-demo

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully containerized, multi-service web application from scratch using Docker and Docker Compose.

You've learned how to:

  • Written a Dockerfile to package a Python Flask app into a container image and run it anywhere.
  • Orchestrated a multi-service stack (web + Redis) with Docker Compose, using environment variables, health checks, and service dependencies.
  • Persisted data across container restarts with named volumes and inspected running services with docker compose logs and docker compose exec.
  • Secret Mission: Enabled Compose Watch for live code reloading during development without rebuilding the image.

Ready to quiz yourself?