Containerize a Python App with Docker
Package a Python Flask API into a Docker container with port mapping.
Introduction
30 Second Summary
Everyone has hit the "it works on my machine" wall. Your code runs perfectly locally, then breaks the moment you share it with someone else or deploy it to a server.
In this project, you will build a Python API and package it inside a Docker container. You'll write a Dockerfile from scratch, build a custom image, and run your app in an isolated environment accessible from your browser.
What You'll Build
A Task Tracker API built with Python and Flask, fully containerized with Docker so it runs identically on any machine.
By the end of this project, you'll have:
- A working Python Flask API with endpoints to list and add tasks.
- A custom Docker image built from a Dockerfile you wrote from scratch, optimized with layer caching and a .dockerignore file.
- Hands-on experience with core Docker commands including docker build, docker run, and docker ps.
- Secret Mission: Harden your image with a multi-stage build and a non-root user for production security.
Are there any prerequisites?
You'll need basic Python knowledge and familiarity with the command line. No prior Docker experience is required.
Before We Start
Before We Start...
Install Docker Desktop
Before you can run your Task Tracker API inside a container, you need Docker on your machine. Docker lets you package an application with everything it needs to run, then execute it in an isolated environment called a container.
In this step, you'll install Docker Desktop, verify the engine is running, and set up your project folder.
In this step, get ready to:
- Install Docker Desktop on your Windows machine.
- Verify Docker is running with a test container.
- Create and open your project folder in VS Code.
Download and install Docker Desktop
Docker Desktop is the application that runs the Docker engine on your local machine. It handles everything behind the scenes so you can build images and run containers from your terminal.
- Open your browser and go to docker.com/products/docker-desktop.
- Click the download button for Windows AMD64.
- Once the download finishes, double-click Docker Desktop Installer.exe to launch the installer.
- Select the per-user installation mode when prompted (no admin rights needed).
- Ensure the Use WSL 2 option is checked.
- Click Install and wait for the installation to complete.
- Launch Docker Desktop after installation finishes.
What is WSL 2?
WSL 2 (Windows Subsystem for Linux 2) is a lightweight Linux kernel built into Windows. Docker Desktop uses it as its backend to run Linux containers on your Windows machine without a full virtual machine.
What happens during installation?
The installer places Docker Desktop in %LOCALAPPDATA%\Programs\DockerDesktop. It also installs the docker CLI so you can use Docker commands from your terminal.
Docker Desktop may take a minute or two to fully start after you launch it. Wait until the whale icon in your system tray is steady (not animating) before moving on.
Verify Docker is running
Now that Docker Desktop is installed, you'll confirm the engine is running by executing two quick commands in your terminal.
- Press the Windows key to open your search bar.
- Type Windows Terminal and press Enter to open it.
- Check that the Docker CLI is available by running this command:
docker --version
✔️ I see a version number
You should see something like Docker version 27.x.x or similar. Docker is installed and ready.
ⓧ Command not found
This means the Docker CLI is not in your system PATH. Try these fixes:
- Close and reopen Windows Terminal so it picks up the new PATH variable.
- Make sure Docker Desktop is fully running (check for the whale icon in your system tray).
- If the issue persists, restart your computer and launch Docker Desktop again before retrying.
- Run a test container to confirm Docker can pull images and start containers:
docker run hello-world
You should see Hello from Docker! followed by a message confirming your installation is working correctly.
Seeing an error instead?
- If you see a message about the Docker daemon not running, make sure Docker Desktop is fully started (whale icon is steady in the system tray).
- If you see a network error, check your internet connection. Docker needs to download the hello-world image from Docker Hub.
- help me troubleshoot my docker run hello-world error
Create the project folder
With Docker confirmed working, you'll create a folder to hold all your project 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
- Confirm the folder was created by running:
ls
The folder will be empty, but that confirms you're in the right place.
- Open this folder in VS Code by running:
code .
VS Code opens with the task-tracker folder loaded in the Explorer sidebar.
VS Code not opening?
- If code is not recognized, you may need to install VS Code first. Download it from code.visualstudio.com and run the installer.
- During VS Code installation, check the Add to PATH option so that the code command works from your terminal.
- After installing, close and reopen Windows Terminal, then retry code . from inside the task-tracker folder.
- help me get VS Code working from my terminal
Your environment is ready. Next up, you'll build the Python app that Docker will containerize.
Build the Python App
Docker is installed and running on your machine. Now you need an application to containerize.
In this step, you'll write a small Python API that tracks tasks. Once it works locally, you'll have something real to package into a Docker image in the next step.
In this step, get ready to:
- Create a requirements file to pin your Python dependency.
- Write a Flask API server with three endpoints.
- Test the app locally to confirm it works before containerizing.
Create the requirements file
Before writing any Python code, you need to declare your project's dependencies. A requirements.txt file tells pip exactly which packages to install and at which version.
- In VS Code, press Ctrl+N to create a new file.
- Add the project's only dependency by pasting the following into the file:
flask==3.1.3
What does this file do?
Pinning flask==3.1.3 locks the exact version of Flask your project uses. This makes your builds reproducible. Anyone who runs pip install -r requirements.txt gets the same version every time.
- Press Ctrl+S to save.
- Name the file requirements.txt and save it in the task-tracker folder.
- Confirm requirements.txt appears in the Explorer sidebar on the left.
✔️ Awesome, I've got everything!
Great. Make sure you saved the file before moving on.
ⓧ I'd like to double check the full code
flask==3.1.3
Write the API server
This is the application Docker will eventually package. You'll build a Flask API with three endpoints that manage a list of tasks.
- Press Ctrl+N to create another new file in VS Code.
- Start building the app by pasting the following code into the file:
from flask import Flask, jsonify, request
# Create the Flask application
app = Flask(__name__)
# Sample tasks to start with
tasks = [
{"id": 1, "title": "Learn Docker basics", "done": False},
{"id": 2, "title": "Build a container image", "done": False},
]
# Home endpoint - returns a welcome message
@app.route("/")
def home():
return jsonify({"message": "Welcome to the Task Tracker API!", "container": True})
# List all tasks
@app.route("/tasks")
def get_tasks():
return jsonify(tasks)
What does this code do?
The first few lines import Flask and create the app instance. The tasks list holds sample data in memory.
The home() function responds to GET / with a welcome message. The get_tasks() function responds to GET /tasks with the full task list as JSON.
- Add the remaining code below what you just pasted:
# Add a new task (accepts JSON with a "title" field)
@app.route("/tasks", methods=["POST"])
def add_task():
data = request.get_json()
if not data or "title" not in data:
return jsonify({"error": "Title is required"}), 400
new_task = {"id": len(tasks) + 1, "title": data["title"], "done": False}
tasks.append(new_task)
return jsonify(new_task), 201
# Run the server on all interfaces, port 5000
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
What does this code do?
The add_task() function handles POST /tasks requests. It reads JSON from the request body, validates that a title field exists, and appends a new task to the list.
The if __name__ == "__main__" block starts the server on port 5000. Binding to 0.0.0.0 means the server accepts connections from any network interface. This is important later when running inside a container.
- Press Ctrl+S to save.
- Name the file app.py and save it in the task-tracker folder.
- Confirm app.py appears in the Explorer sidebar alongside requirements.txt.
Don't see both files in the sidebar?
Make sure you saved both files into the task-tracker folder (the folder open in VS Code). If a file ended up somewhere else, drag it into the correct folder using the Explorer sidebar.
help me find where my files saved in VS Code
✔️ Awesome, I've got everything!
Great. Double check you saved app.py before moving on.
ⓧ I'd like to double check the full code
from flask import Flask, jsonify, request
app = Flask(__name__)
tasks = [
{"id": 1, "title": "Learn Docker basics", "done": False},
{"id": 2, "title": "Build a container image", "done": False},
]
@app.route("/")
def home():
return jsonify({"message": "Welcome to the Task Tracker API!", "container": True})
@app.route("/tasks")
def get_tasks():
return jsonify(tasks)
@app.route("/tasks", methods=["POST"])
def add_task():
data = request.get_json()
if not data or "title" not in data:
return jsonify({"error": "Title is required"}), 400
new_task = {"id": len(tasks) + 1, "title": data["title"], "done": False}
tasks.append(new_task)
return jsonify(new_task), 201
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Test it locally first
Before handing the app to Docker, confirm it actually runs. Testing locally catches typos and import errors early.
- Open the VS Code integrated terminal by pressing Ctrl+` (backtick key, above Tab).
- Install the Flask dependency by running this command:
pip install -r requirements.txt
What does this command do?
This reads your requirements.txt file and installs every package listed in it. Right now that's just Flask 3.1.3 and its dependencies.
- You should see output showing packages being downloaded and installed. Look for Successfully installed near the end.
Seeing 'pip is not recognized'?
This means Python isn't installed or isn't on your system PATH. You need Python installed locally to test the app before containerizing it. Download it from python.org and make sure to check the "Add to PATH" box during installation.
If you have Python but pip doesn't work, try python -m pip install -r requirements.txt instead.
help me get pip working on Windows
- Start the API server by running:
python app.py
- You should see output similar to * Running on http://0.0.0.0:5000 in the terminal. This means Flask is serving your app.
- Open your browser and visit http://localhost:5000.
- You should see a JSON response: {"container":true,"message":"Welcome to the Task Tracker API!"}.
- Return to your VS Code terminal and press Ctrl+C to stop the server.
Browser showing 'This site can't be reached'?
Make sure the server is still running in your terminal. If you see a Python traceback instead of the "Running on" message, check app.py for typos. Compare your code against the double-check tab above.
Also confirm you're visiting http://localhost:5000 (not https). Flask's development server doesn't use HTTPS.
help me debug why my Flask app isn't responding in the browser
Your Python app is working locally. Next up, you'll write a Dockerfile that teaches Docker how to package this app into a portable image.
Write the Dockerfile
Your Flask app works locally, but it depends on your specific Python version and installed packages. Docker solves this by packaging your app with everything it needs into a portable image that runs identically on any machine.
A Dockerfile is the recipe Docker follows to build that image. In this step, you'll write a Dockerfile that installs your dependencies, copies your code, and configures the container to start your server.
In this step, get ready to:
- Create a Dockerfile with optimized layer ordering for fast rebuilds.
- Configure the container's startup command.
- Add a .dockerignore file to keep builds clean and fast.
Create the Dockerfile
Each instruction in a Dockerfile creates a "layer" that Docker caches. The order of these instructions matters for build speed, because Docker re-runs every layer after one that changes.
- In VS Code, create a new file called Dockerfile in your task-tracker folder.
- Make sure there is no file extension. The file is named Dockerfile exactly, not Dockerfile.txt.
- Add the dependency installation layer by pasting this code into your Dockerfile:
# Use a lightweight Python image as the base
FROM python:3.13-slim
# Set the working directory inside the container
WORKDIR /app
# Copy the dependency list first (for layer caching)
COPY requirements.txt ./
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
What does this code do?
- FROM python:3.13-slim tells Docker to start from a lightweight Python 3.13 image. The -slim variant is much smaller than the full image while staying compatible with all Python packages.
- WORKDIR /app sets /app as the working directory inside the container. All subsequent commands run from here.
- COPY requirements.txt ./ copies only the dependency file first. This is intentional.
- RUN pip install --no-cache-dir -r requirements.txt installs your Python packages. The --no-cache-dir flag prevents pip from storing downloaded files inside the image, keeping it smaller.
By copying requirements.txt before the rest of the code, Docker can cache the dependency layer. Next time you rebuild after a code change, Docker skips the slow pip install step entirely.
- Save Dockerfile.
- You should see Dockerfile appear in your VS Code Explorer sidebar with no file extension.
Don't see Dockerfile in the sidebar?
- Check that the file is named exactly Dockerfile with a capital D and no extension. If VS Code added .txt automatically, rename it.
- Make sure you created the file inside the task-tracker folder, not a subfolder.
Why isn't my Dockerfile showing up correctly in VS Code?
Copy source code and configure startup
The dependency layer is set. Now you need to bring in your application code and tell Docker how to start your server.
- In your Dockerfile, add these lines below the RUN pip install line:
# Copy the rest of your application code
COPY . .
# Document the port the app listens on
EXPOSE 5000
# Start the Flask server when the container runs
CMD ["python", "app.py"]
What does this code do?
- COPY . . copies all remaining files from your project folder into the container's /app directory.
- EXPOSE 5000 documents that the container listens on port 5000. It does not actually publish the port. You still need the -p flag at runtime to make it accessible.
- CMD ["python", "app.py"] defines the default command that runs when the container starts. This launches your Flask server.
Notice that COPY . . comes after the RUN pip install line. If you changed this order, Docker would re-install all dependencies every time you edit app.py. This separation is what makes rebuilds fast.
- Save Dockerfile.
- You won't see a visible change yet. In the next step, you'll build this Dockerfile into a Docker image and run it as a container.
✔️ Awesome, I've got everything!
Great. Double check you've saved your Dockerfile before moving on.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
Create the .dockerignore
When Docker builds an image, it sends your entire project folder to the Docker engine as the "build context." A .dockerignore file tells Docker which files to skip, keeping your image clean and your builds fast.
- Create a new file called .dockerignore in your task-tracker folder.
- Add the following exclusion list to your .dockerignore:
__pycache__
*.pyc
.git
Dockerfile
.dockerignore
venv
.env
README.md
What does this file do?
- __pycache__ and *.pyc are compiled Python bytecode files. They get regenerated inside the container anyway.
- .git is your version control history. Not needed inside the image.
- Dockerfile and .dockerignore are build instructions, not application code.
- venv and .env are local environment files that should never end up in a container image.
Without this file, Docker would copy everything into the build context, including files that bloat the image or leak sensitive data like environment variables.
- Save .dockerignore.
- You should see .dockerignore in your VS Code Explorer sidebar alongside Dockerfile, app.py, and requirements.txt.
Don't see .dockerignore in the sidebar?
- Files starting with a dot are sometimes hidden by default. In VS Code, they should still appear in the Explorer sidebar.
- Make sure the file starts with a dot (.dockerignore) and has no additional extension.
Why can't I see my .dockerignore file in VS Code?
Your Dockerfile and .dockerignore are ready. Next up, you'll build this Dockerfile into a real Docker image and run your app inside a container.
Build and Run Your Container
You have a complete Dockerfile and a .dockerignore ready to go. Now it's time to turn them into a real, running container.
In this step, you'll use the Docker CLI to build your image, start a container, and interact with it using commands you'll use every day as a developer.
In this step, get ready to:
- Build a Docker image from your Dockerfile.
- Run the image as a container with port mapping.
- Manage your container with core Docker commands.
Build the Docker image
A Docker image is a snapshot of your application and its environment. Building an image reads your Dockerfile and executes each instruction as a layer.
- In Windows Terminal, make sure you're inside the task-tracker folder.
- Build your Docker image by running this command:
docker build -t task-tracker .
What does this command do?
- docker build tells Docker to read the Dockerfile and create an image.
- -t task-tracker tags (names) the resulting image so you can reference it later.
- . sets the build context to the current directory. Docker sends all non-ignored files here into the build process.
- Watch the output as Docker executes each layer of your Dockerfile. The final line shows your image ID and the tag you assigned.
- Confirm the image exists by running:
docker images
You should see task-tracker listed with a latest tag and a size around 200 MB.
Build failed with an error?
- Make sure Docker Desktop is running. You should see the whale icon in your system tray.
- Check that your terminal is inside the task-tracker folder (where the Dockerfile lives).
- Verify your Dockerfile has no typos by comparing it with the double-check tab at the end of the previous step.
Help me troubleshoot my Docker build error.
Run the container
An image is just a blueprint. To actually start your app, you run a container from it. Port mapping connects a port on your machine to the port inside the container so you can access the app in your browser.
- Start your container by running this command:
docker run -d -p 8080:5000 --name my-tracker task-tracker
What does this command do?
- -d runs the container in detached mode (in the background), so your terminal stays free.
- -p 8080:5000 maps port 8080 on your machine to port 5000 inside the container. Your Flask app listens on 5000, but you'll access it on 8080.
- --name my-tracker gives the container a human-readable name instead of a random ID.
- task-tracker is the image you just built.
- Open your browser and visit http://localhost:8080 to see your app running inside Docker.
You should see a JSON response with {"container":true,"message":"Welcome to the Task Tracker API!"} confirming the API is live.
Don't see the JSON response?
- Make sure port 8080 isn't already in use by another application on your machine.
- Run docker ps to confirm your container is listed as running. If it's not there, run docker logs my-tracker to see what went wrong.
- Check that the -p flag is 8080:5000 (host:container), not 5000:8080 reversed.
Help me debug why my Docker container isn't responding on localhost.
Explore Docker commands
Now that your container is running, you have a handful of commands for inspecting and managing it. These are the commands you'll reach for constantly when working with Docker.
- List all running containers by running:
docker ps
You should see my-tracker listed with its container ID, the image name, status, and port mapping.
- View the container's log output by running:
docker logs my-tracker
You should see Flask's startup message confirming it's running on port 5000 inside the container.
- Open a shell inside the running container by running:
docker exec -it my-tracker bash
What does this command do?
docker exec runs a new command inside a running container. The -i flag keeps your input stream open, and -t allocates a terminal so you can type commands interactively.
You're now inside the container's filesystem. Try running ls to see your app files, or python --version to confirm the Python version. Type exit when you're done exploring.
- Type exit to leave the container shell and return to your host terminal.
- Stop the running container by running:
docker stop my-tracker
- Remove the stopped container by running:
docker rm my-tracker
Why stop and remove separately?
docker stop gracefully shuts down the container's processes but keeps the container on disk. docker rm deletes the stopped container entirely.
You need to remove the old container before you can reuse the name my-tracker for a new one in the next step.
Getting a 'name already in use' error?
- This means a container named my-tracker already exists. Run docker rm my-tracker to remove it, then try again.
- If the container is still running, stop it first with docker stop my-tracker before removing.
Help me fix a Docker container name conflict.
You've built an image, run a container, inspected it, and cleaned it up. Next, you'll see how Docker's layer caching makes rebuilds lightning fast when you change your code.
See Layer Caching in Action
Your Docker image is built and you know how to manage containers. Now let's see one of Docker's most powerful performance features: layer caching.
Every instruction in your Dockerfile creates a layer. When you rebuild, Docker checks whether each layer's inputs changed since the last build. If nothing changed, Docker reuses the cached result and skips the work entirely.
In this step, get ready to:
- Edit your app code and rebuild the Docker image to observe layer caching.
- Run the updated container and verify your change is live.
Make a code change and rebuild the image
To see caching in action, you need to change something in your source code without touching the dependencies. This lets you watch Docker skip the expensive dependency-installation layer and only rebuild the layer that actually changed.
- In VS Code, open app.py and find the home() function.
- Find this line:
return jsonify({"message": "Welcome to the Task Tracker API!", "container": True})
- Change the message string to something different. For example, replace Welcome to the Task Tracker API! with Task Tracker is live inside Docker! so the line looks like this:
return jsonify({"message": "Task Tracker is live inside Docker!", "container": True})
- Save app.py.
- Rebuild the Docker image by running this command:
docker build -t task-tracker .
Watch the build output closely. The dependency-related steps complete almost instantly because Docker reused the cached results from your last build.
What should I see in the build output?
Docker labels each build step to indicate whether it ran fresh or reused a cached layer. You should see the following behaviour:
- The WORKDIR step is cached because the working directory did not change.
- The COPY requirements.txt step is cached because you did not edit the requirements file.
- The RUN pip install step is cached because requirements.txt has not changed since the last build.
- Only the final COPY . . step runs fresh because your source code changed.
The rebuild finishes in about one second instead of the original ten or more. That speed difference is layer caching at work.
Not seeing cached layers on the pip install step?
- Make sure you did not edit requirements.txt. If the requirements file changed, Docker invalidates that layer and every layer after it.
- If you accidentally cleared your build cache, the first rebuild after clearing will re-run all layers. Run the build command a second time to see caching kick in.
Still stuck? Why isn't Docker caching my pip install layer when I rebuild?
Run the updated container and verify your change
Now that you have a freshly built image with your updated code, start a new container from it to confirm the change is live.
- Start the updated container by running this command:
docker run -d -p 8080:5000 --name my-tracker task-tracker
What does this command do?
- -d runs the container in detached mode so your terminal stays free.
- -p 8080:5000 maps port 8080 on your machine to port 5000 inside the container.
- --name my-tracker gives the container a recognizable name for easy management.
- Open your browser and visit http://localhost:8080.
- You should see your updated message in the JSON response, confirming the rebuilt image contains your code change.
Seeing an error about the container name already in use?
If Docker says the name my-tracker is already in use, a previous container with that name still exists. Remove it first:
docker rm my-tracker
Then re-run the docker run command above.
Need more help? Why is Docker saying my container name is already in use?
Why does separating COPY requirements.txt from COPY . . matter?
Your Dockerfile copies requirements.txt on its own line before copying the rest of your code. This means the pip install layer only rebuilds when your dependencies actually change.
If you had a single COPY . . followed by pip install, every code edit would invalidate the dependency layer too. Docker would reinstall all your packages on every rebuild, wasting time.
Your containerized app is running with the updated code. You just experienced Docker's layer caching firsthand. This is the foundation of fast, efficient container builds in production.
Secret mission
Multi-Stage Build with Non-Root User
Your Docker image works, but it's not production-ready. Real-world containers need to be lean and secure. Can you create a multi-stage build that separates build-time tools from runtime, and lock down the container with a non-root user?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker container my-tracker (running, mapped to port 8080).
- Docker image task-tracker (plus base images like python:3.13-slim).
- Local project folder task-tracker on your Desktop.
- Docker Desktop application installed on your machine.
Keep everything running
No action needed. Choose this if you're still actively building or plan to continue experimenting with Docker commands.
- Your container my-tracker stays running and accessible at http://localhost:8080.
- Your Docker images remain cached locally for fast rebuilds.
- Docker Desktop continues running in the background, using some memory and CPU.
Pause - I'll come back to this later
Stop your running container and quit Docker Desktop to free up memory. Your images and project files stay on disk so you can pick up where you left off.
- Stop the running container by running:
- Right-click the Docker Desktop icon in your system tray and select Quit Docker Desktop.
docker stop my-tracker
When you come back, launch Docker Desktop again, then restart your container with:
docker start my-tracker
Delete - I don't want to use this again
Remove all project resources and start fresh. This frees disk space used by Docker images and project files.
- Stop and remove the container by running:
docker stop my-tracker
docker rm my-tracker
- Remove all unused Docker images, containers, and build cache by running:
docker system prune -a
Docker will ask you to confirm. Type y and press Enter.
- Delete the project folder by running:
cd ~/Desktop
rm -rf task-tracker
- Uninstall Docker Desktop by opening the Windows Start menu, then selecting Settings > Apps > Apps & features.
- Find Docker Desktop in the list and select Uninstall.
- Confirm by selecting Uninstall again.
Nice Work!
Nice Work!
You just containerized your first application with Docker. You went from a plain Python file on your machine to a fully packaged, portable container serving an API on localhost.
You've learned how to:
- Built a Python Flask API and wrote a Dockerfile from scratch with optimized layer ordering for fast rebuilds.
- Used core Docker CLI commands to build images with docker build, run containers with docker run, and manage them with docker ps, docker stop, and docker rm.
- Understood layer caching and created a .dockerignore to keep builds lean and fast.
- Implemented a multi-stage build with a non-root user for a production-ready, secure image.
Ready to quiz yourself?