Python: Dockerizing Python Apps with Dockerfile, Compose, and Multi-Stage Builds

Your Flask app runs fine on your laptop. You push it to a server, and it dies on a missing system library you forgot you had installed two years ago. Python Docker fixes this once and for all: you package your app, its exact Python version, every dependency, and the system libraries into one image that runs the same on your machine, your teammate’s machine, and production.

“Docker is not a virtualization technology, it’s an application delivery technology.”

Solomon Hykes, Docker creator

Last Updated: July 2026 | Tested on: Python 3.14.6, Docker 29.3 | Difficulty: Advanced | Reading Time: 16 minutes

Here is the everyday version. Think of a Docker image like a meal-prep lunchbox. You cook everything at home, pack the rice, the curry, the spoon, and the napkin into one sealed box, and then you can eat the exact same meal at your desk, on a train, or in a park. Nothing depends on what is in the kitchen wherever you happen to be. A Docker image is that sealed box for your Python app: the code, the Python runtime, and every dependency travel together.

A Docker image is also like a snapshot of a virtual machine, except it is far lighter. It shares the host operating system kernel, starts in milliseconds, and uses a fraction of the memory. You describe the image in a file called a Dockerfile. A Dockerfile is just a recipe that says “start from Python 3.14.6, copy my requirements, install dependencies, copy my code, run this command.”

A developer friend of mine, Rahul, deployed a Flask API (Application Programming Interface) to three different cloud providers in one afternoon. Same Dockerfile, same image, zero environment-specific bugs. That is the Docker promise, and once you feel it, you will never go back to copying files onto a server and hoping.

docker runMulti-stage BuildCOPY –from=builderStage 1: BuilderInstall build toolsCompile dependenciesStage 2: ProductionCopy only artifactsMinimal image sizeFROM python:3.14-slimBase image layerCOPY requirements.txtRUN pip installCOPY . .Application codeCMD python main.pyDefault commandDocker Image(read-only layers)Docker Container(writable layer on top)Python Docker: How Image Layers Stack from Base to Running Container

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The diagram shows a Python Docker image as a stack of layers, bottom to top: the base image (FROM python:3.14-slim), the dependency install (COPY requirements.txt then pip install), your application code (COPY . .), and the startup command (CMD). Each layer is cached, which is exactly why the order matters. Change only your code, and Docker rebuilds from the COPY . . layer up, reusing everything below it. Change your dependencies, and the slower pip install layer rebuilds too. The multi-stage build on the left, covered later, shrinks the final image by leaving the build tools behind in the builder stage.

Prerequisites

Every Python Docker workflow starts with Docker Desktop (it bundles the Docker engine and Compose), so install that first. You should be comfortable with one Python web framework first, so read the Flask tutorial or the FastAPI tutorial before the web app examples here. A requirements.txt from the virtual environments post also helps, since that is the file Docker will install from.

Install and Verify Docker

After installing Docker Desktop, start it and confirm the command-line tools are on your path. The first command checks the Docker engine, the second checks Compose (which ships with Docker now, no separate install).

📄 Terminal: confirm Docker is installed

docker --version
docker compose version

▶ Output

Docker version 29.3.1, build c2be9cc
Docker Compose version v5.1.1

What happened here: Two version lines, and you are ready. Your numbers may be a little higher than mine since Docker ships updates often, but as long as both commands print a version instead of an error, the engine and Compose are both working. If docker --version works but other commands complain that they “cannot connect to the Docker daemon,” the engine is installed but not running. Open Docker Desktop and wait for the whale icon to go steady, then try again.

Quick Win: Containerize a FastAPI App

Let us containerize a tiny FastAPI service so you feel the payoff fast. Three files: the app, a requirements list, and a Dockerfile. Start with the app, a small notes API seeded with notes from two teammates, Anvi and Aviraj.

📄 main.py: a small FastAPI notes service

from fastapi import FastAPI

app = FastAPI(title="Notes API")

notes = [
    {"id": 1, "author": "Anvi", "text": "Ship the Docker image today"},
    {"id": 2, "author": "Aviraj", "text": "Add a healthcheck route"},
]


@app.get("/")
def root():
    return {"status": "ok", "service": "notes-api"}


@app.get("/notes")
def list_notes():
    return {"count": len(notes), "notes": notes}

📄 requirements.txt: pinned for reproducible builds

fastapi==0.138.0
uvicorn==0.49.0

📄 Dockerfile: the recipe for the image

FROM python:3.14-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

📄 Terminal: build the image, then run the container

docker build -t notes-api .
docker run -p 8000:8000 notes-api

With the container running, open another terminal and hit the two routes. These are the exact JSON (JavaScript Object Notation) responses the containerized API returns.

▶ Output: curl http://localhost:8000/ and /notes

{"status":"ok","service":"notes-api"}
{"count":2,"notes":[{"id":1,"author":"Anvi","text":"Ship the Docker image today"},{"id":2,"author":"Aviraj","text":"Add a healthcheck route"}]}

What happened here: docker build -t notes-api . read the Dockerfile in the current folder (the . at the end), ran each step, and saved the result as an image tagged notes-api. docker run -p 8000:8000 notes-api started a container from that image and mapped your laptop’s port 8000 to the container’s port 8000, so localhost:8000 reaches uvicorn inside. The JSON above is real FastAPI output, verified on Python 3.14.6 with FastAPI 0.138.0. The win that matters: that same image runs byte-for-byte identical on any machine with Docker, no “but it worked here” ever again.

The Dockerfile, Line by Line

That Dockerfile is small, but every line earns its place. Read it like a recipe card taped above the stove: each line is one step, always done top to bottom, and the order is part of the recipe. Here is what each one does and why it sits where it does.

  • FROM python:3.14-slim picks the base image. The slim tag is a minimal Debian image with Python pre-installed. It is roughly 150 MB on disk versus close to 1 GB for the full python:3.14 image, because it drops compilers and tools you rarely ship to production.
  • WORKDIR /app sets the working directory inside the container and creates it if needed. Every command after this runs relative to /app, so you do not sprinkle absolute paths everywhere.
  • COPY requirements.txt . copies just the requirements file first, on purpose. More on why in the caching section below.
  • RUN pip install --no-cache-dir -r requirements.txt installs your dependencies. The --no-cache-dir flag tells pip not to keep its download cache inside the image, which trims size for free.
  • COPY . . copies the rest of your code into /app. This comes after the install so that editing your code does not invalidate the dependency layer.
  • EXPOSE 8000 documents that the app listens on port 8000. It is a note for humans and tools; the actual port mapping happens with -p at docker run time.
  • CMD ["uvicorn", "main:app", ...] is the default command that runs when the container starts. The list form (called exec form) avoids a shell wrapper, so stop signals (Ctrl+C, and the SIGTERM that docker stop sends) reach uvicorn directly and the app shuts down cleanly.

Layer Caching and .dockerignore

This is the one Python Docker idea that separates a 2-second rebuild from a 2-minute one. Each instruction in a Dockerfile creates a layer, and Docker caches every layer. On the next build, Docker reuses a cached layer as long as nothing it depends on has changed. The moment one layer changes, that layer and every layer above it rebuild.

Think of it like a stack of sticky notes on a wall. If you swap the top note, the ones underneath stay put. If you swap a note near the bottom, every note above it has to be re-stuck. That is why we copy requirements.txt and install dependencies before copying the app code. Your code changes ten times a day; your dependencies change once a month. Put the rarely-changing, slow step low in the stack so it almost always comes from cache.

The other half of fast, clean builds is a .dockerignore file. It works like .gitignore: it tells COPY . . what to skip. Without it, you copy your virtual environment, your .git history, and every cache folder straight into the image, which bloats it and can even leak secrets.

📄 .dockerignore: keep junk out of the image

.git
.venv
venv
__pycache__
*.pyc
.pytest_cache
.env
*.md

What happened here: Everything listed is excluded from COPY . .. Skipping .venv and __pycache__ keeps the image small and avoids shipping byte-code compiled for the wrong platform. Skipping .env keeps your secrets out of the image entirely, which is the safe default. Add a .dockerignore to every project before your first build, not after.

Multi-Stage Builds for Smaller Images

Some Python packages need a C compiler to install, but you do not need that compiler once the package is built. A multi-stage build solves this neatly: do the heavy building in one stage, then copy only the finished result into a clean, small final stage. It is like cooking in a messy kitchen, then carrying only the plated dish to the table and leaving the pots behind.

📄 Dockerfile: multi-stage build for production

# Stage 1: Builder
FROM python:3.14-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Production (copy only what we need)
FROM python:3.14-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .

RUN adduser --disabled-password --gecos "" --no-create-home appuser
USER appuser

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

What happened here: The builder stage installs everything into /install. The final stage starts fresh from a clean python:3.14-slim and pulls only that /install folder across with COPY --from=builder. Build tools, compilers, and pip’s leftovers never make it into the shipped image, so it stays small and has a smaller attack surface. The last new piece, adduser plus USER appuser, makes the container run as an ordinary user instead of root. If an attacker ever breaks into the process, they land as appuser with limited rights, not as the all-powerful root account.

Docker Compose for Multi-Service Development

Real apps are rarely one process. You have your Python API, a database, maybe a Redis cache. Starting and wiring all of those by hand with docker run is tedious and easy to get wrong. Docker Compose lets you describe every service in one YAML file and bring the whole stack up with a single command. Think of it as one order slip for the whole table: instead of calling out each dish to the kitchen separately, you hand over the slip and everything arrives together.

📄 docker-compose.yml: app plus PostgreSQL plus Redis

services:
  app:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/myapp
      - REDIS_URL=redis://redis:6379
    depends_on:
      - db
      - redis
    volumes:
      - .:/app  # Live reload during development

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:8-alpine

volumes:
  pgdata:

📄 Terminal: control the whole stack

docker compose up -d     # Start everything in the background
docker compose logs app  # Tail the app's logs
docker compose down      # Stop and remove all services

What happened here: Compose builds your app from the local Dockerfile and pulls ready-made Postgres and Redis images. Because they share a network, your app reaches the database at the hostname db and the cache at redis, which is exactly the names you gave the services. The depends_on block starts the database and cache containers before the app, though note it only controls start order: it does not wait for Postgres to be ready to accept connections, so production setups add a healthcheck or retry logic on top.

The named pgdata volume keeps your database data alive even after docker compose down, so you do not lose everything each time you restart. That bind mount, .:/app, maps your source folder into the container so code edits show up live during development.

Python-Specific Docker Best Practices

  • Use python:3.14-slim, not the full python:3.14. The slim image saves roughly 800 MB and still runs almost everything.
  • Copy requirements.txt before your code so the dependency layer stays cached across code edits.
  • Set ENV PYTHONDONTWRITEBYTECODE=1 so Python skips writing .pyc files you do not need inside a container.
  • Set ENV PYTHONUNBUFFERED=1 so your print and log output appears immediately instead of getting stuck in a buffer.
  • Run as a non-root user with adduser plus USER, as shown in the multi-stage build.
  • Always add a .dockerignore to exclude the virtual environment, __pycache__, and .git.
  • Pin your dependency versions in requirements.txt (for example fastapi==0.138.0) so a rebuild next month installs the same versions, not whatever is newest.

Common Mistakes

Mistake 1: Copying everything before installing dependencies

🚫 Wrong: every code edit reinstalls all dependencies

FROM python:3.14-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt

✅ Correct: dependencies stay cached

FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

Why: In the wrong version, COPY . . sits below the install, so changing a single line of code invalidates the cache for the install layer and pip runs again from scratch. The correct order copies only requirements.txt first, so the slow install layer is reused until your dependencies actually change.

Mistake 2: Baking secrets into the image

🚫 Wrong: the secret is now permanent in the image history

ENV API_KEY=sk-live-9f3a2b7c1d

✅ Correct: pass the secret at run time

docker run -e API_KEY=sk-live-9f3a2b7c1d notes-api

Why: Anything written into the Dockerfile is stored in the image’s layers forever, so anyone who pulls the image can read the key with docker history. Pass secrets at run time with -e, a Compose environment block, or a real secrets manager. The image should never know your production keys.

Conclusion

You can now take any Python app and ship it as a container: write a clean Dockerfile, order the layers so a code-only rebuild takes seconds, keep junk and secrets out with .dockerignore, shrink production images with multi-stage builds, run as a non-root user, and bring up a full app-plus-database stack with Docker Compose. That is the exact Python Docker toolkit teams use to make “works on my machine” mean “works everywhere.” Next, we put this image on autopilot with CI/CD using GitHub Actions, so every push builds and tests your container without you touching a terminal. And if you want the full learning path from beginner to AI/ML, visit the Python + AI/ML tutorial series home.

Frequently Asked Questions

What is Python Docker used for?

Python Docker packages your Python application, its exact Python version, and every dependency into a container, which is a lightweight isolated environment that runs the same way on any machine. It solves “works on my machine” problems and makes deployment to any cloud predictable.

What is the difference between a Docker image and a container?

An image is a read-only template, similar to a class in Python. A container is a running instance of an image, similar to an object created from that class. You build images with docker build and start containers with docker run. One image can run as many containers as you like.

Why use python:slim instead of python:alpine?

Alpine uses musl libc instead of glibc, which breaks many compiled Python packages such as numpy, pandas, and psycopg2, or forces slow source builds. The slim image is Debian-based and works with almost everything. The small size win from alpine is rarely worth the headaches.

How do I handle secrets in a Docker container?

Never put secrets in the Dockerfile or image, because image layers are permanent and readable. Pass them at run time with the -e flag, an environment block in docker-compose.yml, Docker secrets, or a secrets manager such as AWS Secrets Manager or HashiCorp Vault.

Why is my Docker build slow every time?

Usually the cache is being invalidated. If you copy all your code before installing dependencies, every code edit forces pip to reinstall everything. Copy requirements.txt and run pip install before COPY . ., and add a .dockerignore so unrelated files do not trigger rebuilds. Layer order is the biggest speed lever in any Python Docker build.

What Python version does python:3.14-slim use?

The python:3.14-slim tag tracks the latest 3.14 patch release, which is Python 3.14.6 at the time of writing. It is a Debian-based image with a minimal set of system packages, so your container Python matches the version you tested against.

Try It Yourself

Dockerize the application from the FastAPI tutorial end to end. Write a multi-stage Dockerfile that runs as a non-root user, add a .dockerignore, then write a docker-compose.yml that pairs your API with a PostgreSQL database. Run docker compose up and confirm the interactive docs load at http://localhost:8000/docs. As a stretch goal, add --reload to the uvicorn command so it restarts on file changes, then edit one route while the stack is running and watch the live reload pick it up through the bind mount.

Interview Questions on Python Docker

Interviewers rarely ask for definitions. They ask what happens in situations like these.

Q: Why should CMD use the exec form (JSON array) instead of the shell form in a Python container?

With the shell form (CMD uvicorn main:app), Docker wraps the command in /bin/sh -c, so the shell becomes PID 1 and your Python process runs as its child. The shell does not forward signals, so the SIGTERM from docker stop never reaches uvicorn, in-flight requests are cut off, and after the grace period Docker kills the container with SIGKILL. The exec form (CMD ["uvicorn", "main:app"]) makes the Python process PID 1, so it receives signals directly and can shut down gracefully.

Q: Your container runs fine, but no log output appears on the server until the app crashes, and then everything prints at once. What is happening?

Python buffers stdout when it is not attached to a terminal, which is exactly the situation inside a container. Logs sit in the buffer until it fills or the process exits, which is why they all appear in one burst at crash time. Set ENV PYTHONUNBUFFERED=1 in the Dockerfile (or run Python with -u) so every print and log line is flushed immediately to docker logs.

Q: You built an image on an ARM MacBook, pushed it, and the Linux x86 server fails with “exec format error.” What went wrong and how do you fix it?

The image was built for the arm64 architecture, and the server’s Central Processing Unit (CPU) is amd64, so the binaries inside simply cannot execute. Build for the target platform with docker build --platform linux/amd64 ., or use docker buildx to produce a multi-platform image that carries both architectures under one tag. In CI this rarely bites because the runners are usually amd64 already, which is one more argument for building images in CI instead of on laptops.

Q: You add RUN rm -rf on a large folder near the end of the Dockerfile, but the image does not get any smaller. Why?

Image layers are additive: a later RUN rm only records a “whiteout” marker in a new layer, while the earlier layer that created the files still ships with the image at full size. To actually save space, create and delete the files within the same RUN instruction, or better, use a multi-stage build so the heavy intermediate files never enter the final image at all.

Q: How does a multi-stage build reduce both image size and attack surface?

The builder stage carries compilers, headers, and pip caches needed to install packages, but COPY --from=builder pulls only the finished site-packages into a fresh final stage. Everything else is discarded, so the shipped image is smaller and faster to pull. It also contains fewer binaries and libraries, which means fewer CVEs for security scanners to flag and fewer tools available to an attacker who gets code execution inside the container.

Q: In Compose, your API starts before Postgres finishes initializing and crashes with “connection refused,” even though depends_on is set. Why, and what is the fix?

depends_on only orders container startup; it does not wait for the process inside to be ready, and Postgres needs a few seconds after starting before it accepts connections. The clean fix is a healthcheck on the db service (for example pg_isready) plus depends_on with condition: service_healthy on the app. A robust app also retries its initial database connection with a short backoff, since in production the database can restart at any time.

Previous: Python: Packaging Your Project (pyproject.toml, Poetry)

Next: Python: Code Quality with Ruff, mypy, Pre-commit Hooks

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *