In the first two parts of this series, we focused on understanding Docker.
We talked about why Docker was created, why the infamous "works on my machine" problem existed for years, and how Docker solved it. We also looked behind the scenes at Docker's architecture and understood the relationship between the Docker Client, Docker Engine, Docker Hub, Images, and Containers.
Now it's time to put all of that knowledge into practice.
Today, we'll take a simple FastAPI project and package it into a Docker Image that can run consistently across different environments. The project itself is intentionally small because the goal isn't to learn FastAPI: it's to understand how Docker packages applications.
I'll also attach the GitHub repository used in this article so you can clone it and follow along.
github link: https://github.com/ladicodes/docker-demo
Our Project
Before introducing Docker, our project looks like this:
docker-workshop-api/
│
├── main.py
├── requirements.txt
└── README.md
At this stage, it's just another Python application.
It works because our machine already has Python installed, along with every dependency the project requires.
But if we send this same project to another developer, they may run into completely different problems:
- Different Python versions
- Missing dependencies
- Different operating systems
- Different environment configurations
This is exactly the kind of inconsistency Docker was designed to eliminate. Instead of sending just our source code, Docker allows us to package the entire runtime environment alongside the application.
The Dockerfile: Docker's Instruction Manual
Docker doesn't magically understand how to package your application. You have to tell it exactly what to do: that's where the Dockerfile comes in.
A Dockerfile is simply a text file containing step-by-step instructions Docker follows to build an Image.
Let's build ours one instruction at a time.
Step 1: Choosing a Base Image
FROM python:3.12-slim
Every Docker Image starts from another Image. Instead of installing Python ourselves, we're using the official Python Image published on Docker Hub.
You'll also notice we're using python:3.12-slim instead of the regular Python Image.
The slim variant removes unnecessary packages while keeping everything needed to run Python applications. This produces smaller Images, which means faster downloads, quicker deployments, and lower storage usage. We'll go much deeper into Image optimization in Part 4.
Step 2: Setting the Working Directory
WORKDIR /app
Think of this as running:
cd /app
inside the container.
From this point onward, every instruction in the Dockerfile executes relative to this directory. Having a dedicated working directory keeps the Image organized and prevents files from being scattered throughout the filesystem.
Step 3: Copying Dependencies First
COPY requirements.txt .
This line copies only the dependency file into the Image. You might be wondering: Why not copy everything immediately?
The answer is Docker Layers. Docker builds Images layer by layer.
If your dependencies rarely change but your application code changes frequently, Docker can reuse the dependency layer during future builds instead of reinstalling everything from scratch. That dramatically speeds up build times. It's a small decision now that saves minutes later in larger projects.
Step 4: Installing Dependencies
RUN pip install --no-cache-dir -r requirements.txt
The RUN instruction executes commands while the Image is being built. Here, Docker installs every package listed in requirements.txt.
The --no-cache-dir flag tells pip not to keep unnecessary installation cache files, helping reduce the final Image size. Every instruction like this becomes part of the final Image.
Step 5: Copying the Application
COPY . .
Now that our dependencies are installed, we copy the rest of the project into the Image. At this point, Docker has everything it needs:
- Application code
- Dependencies
- Configuration
- Runtime environment
The application has officially been packaged.
Step 6: Documenting the Application Port
EXPOSE 8000
This tells anyone using the Image that the application is expected to listen on port 8000. It's worth noting that EXPOSE doesn't automatically make the application accessible from your computer. It simply documents which port the application uses inside the container.
We'll map that port when we actually run the container.
Step 7: Starting the Application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
This instruction defines what should happen whenever a container starts from this Image. In our case, Docker launches the FastAPI application using Uvicorn.
You may notice the application listens on:
0.0.0.0
This often confuses beginners. 0.0.0.0 isn't the address you'll open in your browser. Instead, it tells the application to listen on all available network interfaces inside the container.
From your own computer, you'll access the application through:
http://localhost:8000, by entering uvicorn main:app --reload
because Docker maps your computer's port to the container's port.
Building Our First Docker Image
Now that our Dockerfile is complete, it's time to package the application.
Run:
docker build -t docker-workshop-api .
Let's quickly break this command down.
docker build: Builds a Docker Image.
-t: Assigns a name (tag) to the Image.
docker-workshop-api: The name we'll use whenever we want to create containers from this Image.
dot(.): Perhaps the most overlooked part of the command. The dot tells Docker to use the current folder as the build context, allowing it to locate the Dockerfile and every file referenced by COPY.
Once the build completes, you can confirm the Image exists by running: docker images
You'll now see your newly created Image listed locally. At this point, nothing is running yet. You've simply created the package.
Creating a Running Container
Now we can create a running application from that Image.
docker run -p 8000:8000 docker-workshop-api
This is where many beginners accidentally mix up Images and Containers.
Remember:
An Image is the package. A Container is the running instance of that package.
The docker run command creates a brand-new Container from the Image before starting the application.
The -p 8000:8000 flag maps:
Your Computer (8000)
│
▼
Container (8000)
This allows your browser to communicate with the application running inside Docker.
If everything goes well, you'll see something similar to:
INFO: Uvicorn running on http://0.0.0.0:8000
Although the application listens on 0.0.0.0 inside the container, you should open:
From there, FastAPI's interactive documentation lets you test every endpoint and confirm your application is running successfully inside Docker.
Where We Go From Here
Congratulations.
You've just Dockerized your first application.
More importantly, you've connected the concepts we've covered throughout this series:
Python Project
│
▼
Dockerfile
│
▼
docker build
│
▼
Docker Image
│
docker run
│
▼
Docker Container
│
▼
Running Application
Understanding this flow is far more valuable than memorizing commands because every Docker project, whether it's a personal project or a production system follows this same journey.
But we're not done yet. Our application works, but there are still important questions to answer.
Why are some Docker Images over 1GB, while others are only a few hundred megabytes?
Why do some projects rebuild in 5 seconds, while others take 5 minutes?
How do production teams build Images that are smaller, faster, and more secure?
That's exactly what we'll explore in Part 4, where we'll dive into Docker layers, caching, .dockerignore, base Images, and practical techniques for optimizing Docker Images like experienced engineers.
See you in the next part.🚀
Top comments (0)