Is your Python project difficult to build from source, hard to share with colleagues or, difficult to deploy to production? Docker is the ideal tool to solve this for your Python project.
Several reasons exist for those hard to deploy projects, and here are just a few:
- The project requires lots of dependencies.
- The project requires outdated versions of some libraries or an outdated compiler. Once you install it, other projects might break in turn.
- You’re running Windows or a Mac, but the software is designed to build and run on Linux
For similar reasons, it can also be hard to run your software in production!
If you’re facing these problems, it’s good to know that there’s an easy fix. It doesn’t require virtualization but instead uses a principle called containerization. Simply put, containerization is a very lightweight alternative to virtualization. The defacto standard in the containerization world is Docker, so that’s what we’ll be using to containerize a sample Python project.
What is a container?
A container is an entity that has everything required to run your software. It packs:
- Your software
- All dependencies
- All system tools and libraries that might be needed
Containers are like virtual machines but more lightweight. For instance, they start almost instantly. Containers virtualize just the operating system, while a VM virtualizes an entire machine with all its hardware. If you want to know how this works exactly, please read up on it at the excellent official documentation.

Docker containers have become a standard. They can run anywhere: from your development PC to a self-hosted server to cloud hosting services like Amazon, Google, and Azure. Containers make it easy to package and ship your software and provide a well-defined environment for it to run in.
What is an image?
A Docker container is always based on an image. You first define an image and then start one or more containers based on it. You can define an image in a file (called the Dockerfile) and this file can be checked into a VCS like git, together with your code. This allows you to document and create the exact environment needed to run your code.
You don’t have to build an image from scratch. Many software projects provide images that containerize their software. For practically all computer languages, including Python, there are multiple base images you can choose from.
Just like Python classes, you can extend such images with your specifics, as I will demonstrate below. By doing so, you are adding a new layer on top of an existing image. Because of this layering, Docker images can be stored and built very efficiently. For example, many images might all share the same Debian Linux base image and extend it with their specific software requirements:

Dockerize your Python project
Creating a container for your software is super easy. After making my first Docker image, my thoughts were roughly this: “Is this all? I must have skipped a step!”
I’ll create a Python project as an example here, but this is just as easy for other languages. You’ll need Docker and uv installed. Once the image is built, you don’t even need Python on your machine to run it. That’s the power of containerization.
A simple web app
Assume we want to create a web service using Flask. We’ll manage its dependencies with uv, which keeps them in a pyproject.toml file and pins the exact versions in a lock file called uv.lock. Let’s create the project and add Flask:
$ uv init --no-package my-webservice $ cd my-webservice $ uv add flask
uv also creates a main.py file. Delete it. For now, we’ll create a Python file just to prove that things are working as expected. I called it app.py:
print("Hello world")
Creating your own Docker Python image
Next, we need to create a Dockerfile that defines our container. Python offers official images on Docker Hub, so we’ll build on top of one of those. The uv documentation explains how to use uv in Docker. Based on that, this is what I came up with:
FROM python:3.15-slim COPY --from=ghcr.io/astral-sh/uv:0.13.7 /uv /uvx /bin/ WORKDIR /app ENV UV_NO_DEV=1 # Install the dependencies first, so Docker can cache this layer COPY pyproject.toml uv.lock ./ RUN uv sync --locked # Then copy our code COPY *.py ./ # Don't run as root inside the container RUN useradd --create-home appuser USER appuser ENV PATH="/app/.venv/bin:$PATH" CMD [ "python", "app.py" ]
Some things to note:
- We use a specific Python version (3.15) and the slim variant of the image. The plain
python:3image changes whenever a new Python version comes out, and the full image is much larger because it contains tools we don’t need. - The
COPY --fromline copies the uv binaries from uv’s official image. The version is pinned too, so your build doesn’t change under your feet. uv sync --lockedcreates a virtual environment in/app/.venvwith exactly the versions fromuv.lock. If the lock file is out of date, the build fails instead of silently installing other versions.UV_NO_DEV=1skips development dependencies.- We copy the dependency files before the code. Docker caches each step, so when you only change your code, it doesn’t have to install all dependencies again.
- By default, a container runs as root. Running as a regular user limits the damage if someone manages to break into your application.
- Putting
/app/.venv/binat the front of the PATH meanspythonis the Python from our virtual environment, with Flask installed.
We also need a .dockerignore file next to the Dockerfile. It tells Docker which files to leave out when it sends your project to the build. Most important is your local .venv: it only works on your own machine and doesn’t belong in the image.
.venv .git __pycache__
To learn more about the syntax of a Dockerfile, it’s best to go directly to the official Dockerfile reference.
To build your image, you need to run the docker build command. If you’re using an advanced IDE like VSCode, you’ll be able to install a Docker extension allowing you to simply right click the Dockerfile, give it a name (called a tag), and start building. In this case, I prefer the command-line because it shows us exactly what we’re doing and keeps us in control of all the options. So we’ll build our image on the command-line:
$ docker build -t my_webservice:latest .
Let’s break it down:
- We’re building an image and tagging it with the name my_webservice and the version latest (which is the default, so you could leave it out)
- The single dot simply means Docker needs to look for the Dockerfile in the current directory
When you run this for the first time, there will be lots of activity:
- Docker pulls the Python image and the uv image first
- Next, we set the working directory to /app
- We copy
pyproject.tomlanduv.lockinto the working directory - uv creates a virtual environment and installs our dependencies in it
- We copy all Python files to the working directory
- Finally, we create a user and switch to it
Docker shows each step as it runs it. The second time you build, most steps finish instantly because Docker reuses the cached layers.
Our image is finished and we can run it with docker run. Let’s try:
$ docker run my_webservice Hello world
Improving our Python Docker image
We have the basics working, so let’s create an actual web service now. Adapt app.py to look like this basic Flask example:
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello_world():
return 'Hello, World!'
Because we’re running a Flask app now, we need to alter the CMD in our Dockerfile too:
CMD [ "python", "-m", "flask", "run", "--host=0.0.0.0", "--port=8000" ]
As you can see, we need Flask to listen on all network interfaces. Otherwise, it would just be listening on localhost inside the docker container and it would be unreachable to the outside world. We also move it from its default port, 5000, to 8000. Port 5000 is often taken on the host already, for example by the AirPlay Receiver on a Mac, and 8000 is the port we’ll use for the production server later too.
Rebuild the Docker image with the same command as before:
$ docker build -t my_webservice:latest .
And since we’ll be running an actual server on port 8000, we need to expose this port. For extra security and to prevent overlapping ports, Docker, by default, won’t expose ports. Docker offers strong isolation, which is more secure as well! You can map a port from the container to a port on your PC with the -p command-line option:
$ docker run -p 8000:8000 my_webservice
Flask tells you which addresses it’s listening on, together with a clear warning:
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
Now head over to http://127.0.0.1:8000/ to see the service in action. You should get a page saying “Hello, World!” and you should see a log entry appear on your command-line.
Use a production server
Flask’s built-in server is great while you develop, but as the warning says, it isn’t made for production. A popular production server for Flask is Gunicorn. Add it to the project:
$ uv add gunicorn
And change the last line of the Dockerfile once more:
CMD [ "gunicorn", "--bind", "0.0.0.0:8000", "app:app" ]
The app:app part means: the object called app in the module app.py. We bind Gunicorn to the same port 8000, so you rebuild the image and run it exactly as before:
$ docker build -t my_webservice:latest . $ docker run -p 8000:8000 my_webservice
Your service is now available at http://127.0.0.1:8000/. The Flask documentation has more options in its section on deploying to production.
Use my pre-built image
If you don’t want to go through all the steps, but still like to try this example, you can run the finished image straight from the GitHub Container Registry. Docker downloads it the first time:
$ docker run -p 8000:8000 ghcr.io/eriky/docker-python-example:latest
Then open http://127.0.0.1:8000/. The image runs on both regular (Intel and AMD) and ARM computers, including Apple silicon Macs.
If you’d like to check out the source code from GitHub, head over to the accompanying repository here. It contains the finished project from this article, plus a test and the pipeline that builds and publishes this image automatically. That pipeline is explained step by step in the CI/CD article.
Keep learning
- Learn how to build and deploy your Python project automatically, using continuous integration.
- If your application is a success, here are some guerrilla tactics to scaling the application without spending millions.