Dockerizing a Python Application
Dockerizing a Python application means packaging your Python runtime, installed dependencies, source code, and startup command into a repeatable container image. It matters because the same image can run on your laptop, in CI, and on a server without depending on whatever Python packages happen to be installed on that machine.
In this lesson you will containerize a small FastAPI app, run it locally, and understand why the Dockerfile is ordered the way it is. The same ideas apply to Flask, Django, workers, CLIs, and background services.
Overview: How Python Apps Run In Docker
A Docker image is a read-only template made from stacked filesystem layers. For a Python app, those layers usually include a Linux base image, Python itself, installed packages from pip, your application files, metadata such as EXPOSE, and a default command. A container is a running or stopped instance of that image with a thin writable layer on top and one main process.
When you run docker build -t python-api:1.0 ., the Docker client sends the build context to the Docker builder. The builder reads the Dockerfile one instruction at a time. Each instruction can create a cached layer. If an instruction and its inputs have not changed, Docker can reuse the existing layer instead of running the step again. This is why Python Dockerfiles should copy dependency manifests before copying application source: changing main.py should not force pip install to run again if requirements.txt did not change.
When you run the resulting image, Docker asks the daemon to create a container. On Linux, the process is isolated with namespaces and resource controls. On Docker Desktop, the Linux daemon runs inside a small VM, but the container model is the same. Docker mounts the image layers read-only, adds the container writable layer, sets environment variables, attaches networking, and starts the command from CMD unless you override it.
Python has a few container-specific details. You normally do not need a project-local virtual environment such as .venv inside the final image because the image already isolates the filesystem. You should avoid baking secrets into ENV or copied files because image layers can preserve data even after a later RUN rm. You should also run the app as a non-root user when possible. Root inside a container is not root on the host in every configuration, but it is still more privilege than most application processes need.
Syntax
The usual workflow is:
docker build -t IMAGE_NAME:TAG .
docker run --rm -p HOST_PORT:CONTAINER_PORT IMAGE_NAME:TAG
| Part | Meaning |
|---|---|
docker build |
Builds an image from a Dockerfile and a build context. |
-t IMAGE_NAME:TAG |
Names the image. Use a specific tag such as python-api:1.0, not latest, for repeatability. |
. |
The build context. Docker can only copy files that are inside this context and not excluded by .dockerignore. |
docker run |
Creates and starts a container from an image. |
--rm |
Removes the container after it exits. The image remains. |
-p HOST:CONTAINER |
Publishes a container port to the host. This is what makes localhost:8000 work. |
A production-style Python Dockerfile often follows this shape:
FROM python:3.12-slim-bookworm AS builder
WORKDIR /app
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim-bookworm
WORKDIR /app
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=builder /opt/venv /opt/venv
COPY main.py .
RUN useradd --create-home --shell /usr/sbin/nologin appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
This uses a pinned base image tag, installs dependencies in a separate builder stage, copies only the virtual environment and app file into the final stage, and starts Uvicorn. EXPOSE 8000 documents that the container listens on port 8000; it does not publish the port. You still need -p 8000:8000 or a Compose ports: entry.
Examples
Example 1: A Minimal FastAPI App
Create a small application file:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello from Dockerized Python"}
Use a dependency file with pinned package versions:
fastapi==0.115.6
uvicorn[standard]==0.32.1
The Dockerfile copies requirements.txt before main.py so dependency installation can stay cached while you edit application code:
FROM python:3.12-slim-bookworm
WORKDIR /app
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
docker build -t python-api:1.0 .
Output:
[+] Building 12.4s (9/9) FINISHED
=> [internal] load build definition from Dockerfile
=> [internal] load .dockerignore
=> [1/5] FROM docker.io/library/python:3.12-slim-bookworm
=> [2/5] WORKDIR /app
=> [3/5] COPY requirements.txt .
=> [4/5] RUN pip install --no-cache-dir -r requirements.txt
=> [5/5] COPY main.py .
=> naming to docker.io/library/python-api:1.0
Docker built a reusable image named python-api:1.0. The base image is pinned to a specific Python and distribution family instead of using python:latest, which is a moving target and can change underneath you.
Example 2: Run The App And Publish The Port
Start a container from the image:
docker run --rm --name python-api -p 8000:8000 python-api:1.0
Output:
INFO: Started server process [1]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000
The app listens on 0.0.0.0 inside the container so Docker can route traffic to it. If the app listened only on 127.0.0.1 inside the container, it would bind to the container loopback interface and host port publishing would not reach it. The -p 8000:8000 flag maps host port 8000 to container port 8000.
Example 3: Development With Compose And A Bind Mount
For local development, Compose can build the image and bind-mount your source file so edits are visible without rebuilding:
services:
api:
build: .
image: python-api:dev
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
ports:
- "8000:8000"
volumes:
- ./main.py:/app/main.py
docker compose up --build
Output:
[+] Building 3.1s (9/9) FINISHED
[+] Running 2/2
- Network pythonapp_default Created
- Container pythonapp-api-1 Started
The bind mount maps a specific host file into the container. This is convenient for development, but it is not how you ship production images. Production should run from files copied into the immutable image so deployments are repeatable.
Example 4: Keep The Build Context Clean
Add a .dockerignore so local caches, virtual environments, Git metadata, and secrets are not sent to the builder:
.git
.venv
__pycache__/
.pytest_cache/
*.pyc
.env
*.pem
coverage/
dist/
Output:
Docker excludes these paths from the build context before COPY can see them.
This keeps builds faster and reduces accidental leaks. If .env contains a real database password, do not copy it into an image and then delete it later; it may remain recoverable from an earlier layer.
How It Works Step By Step
- You run
docker buildwith the current directory as the build context. - Docker reads
.dockerignoreand excludes matching paths before the build begins. - The builder pulls
python:3.12-slim-bookwormif it is not already local. Registries store image manifests that point to content-addressed layers, and Docker downloads only missing layers. WORKDIR /appsets the working directory for later instructions.COPY requirements.txt .creates a layer whose cache depends mainly on that dependency file.RUN pip installinstalls packages into the image. Ifrequirements.txtdoes not change, Docker can reuse this expensive layer.COPY main.py .adds the application code in a later layer, so normal code edits invalidate only this layer and the layers after it.- At runtime Docker creates a container from the image, adds a writable layer, connects networking, applies port publishing, and starts Uvicorn as process ID 1 inside the container.
Common Mistakes
Copying Source Before Installing Dependencies
FROM python:3.12-slim-bookworm
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "main.py"]
This works, but it is cache-hostile. Any change to any copied source file invalidates the COPY . . layer, so pip install runs again. Fix it by copying requirements.txt, installing dependencies, and only then copying the app source.
Thinking EXPOSE Publishes A Port
docker run --rm python-api:1.0
If the Dockerfile contains EXPOSE 8000, this command still does not publish port 8000 to the host. Use docker run --rm -p 8000:8000 python-api:1.0 or a Compose ports: mapping.
Baking Secrets Into The Image
FROM python:3.12-slim-bookworm
ENV DATABASE_PASSWORD=changeme
COPY . .
RUN rm -f .env
CMD ["python", "main.py"]
Even though changeme is only a placeholder here, the pattern is wrong for real secrets. Environment values in a Dockerfile and copied secret files become part of image metadata or layers. Pass secrets at runtime through your orchestrator, Docker secrets, mounted files, or environment variables that are not baked into the image.
Best Practices
- Use pinned base image tags such as
python:3.12-slim-bookworm; avoidlatestfor reproducible builds. - Keep dependency files separate from source copies so Docker can reuse the
pip installlayer. - Add a
.dockerignorefor.venv, caches, Git metadata, logs, build output, and secret files. - Do not copy a local virtual environment into the image. Install dependencies inside the image instead.
- Bind the web server to
0.0.0.0inside the container, not only127.0.0.1. - Remember that
EXPOSEis documentation and metadata; use-por Composeports:to publish. - Run as a non-root user in production images when the app does not need root privileges.
- Use bind mounts for local development source code and copied files for production images.
- Keep real secrets out of Dockerfiles, image layers, and committed Compose files.
Practice Exercises
- Convert a small Flask app into a Docker image. The expected end state is a pinned Python base image, dependency installation before source copy, and a command that binds to
0.0.0.0. - Add a
.dockerignoreto a Python project that contains.venv/,__pycache__/,.pytest_cache/,.env, andrequirements.txt. Keep the dependency file available to the build. - Create a Compose file for development that publishes port
8000and bind-mounts one source directory. Hint: usedocker compose up --buildafter changing the Dockerfile.
Summary
- A Dockerized Python app packages the runtime, dependencies, source, and startup command into one reusable image.
- Docker images are read-only layered templates; containers add a thin writable layer and run a process.
- Copy dependency manifests before source files to protect the expensive
pip installcache layer. EXPOSEdoes not publish a port;-pand Composeports:do.- Use
.dockerignore, pinned base images, non-root users, and runtime secret injection for cleaner, safer Python containers.
