Endpoint 51 Support

Dockerizing a Python API: A Beginner-Friendly Dockerfile Walkthrough

By Simon O'Connor · Updated 18 June 2026 · 12 min read

The classic complaint about shipping software is that it works on your machine and nowhere else. A different Python version, a missing system library, an environment variable that only exists on your laptop, and the same code that ran fine in development falls over the moment it lands somewhere new. A Docker image is the standard answer to that problem. It packages your application together with the environment it needs so the thing that runs in production is the same thing you tested.

The good news is that you do not need to understand all of Docker to get value from it. You need one file, a couple of commands, and an understanding of the few decisions that matter. By the end of this guide you will have written a Dockerfile for a small Python API, built it into an image, and run that image as a container serving requests.

We will keep the example deliberately small so the Dockerfile has nothing to hide behind, then go through it line by line. Two things trip up almost every beginner, and we will spend real time on both: the order of instructions, which decides how fast your rebuilds are, and secrets, which must never be baked into an image. This guide assumes you have Docker installed and a basic Flask app in mind.

The app we are containerising

Before there can be a Dockerfile, there has to be something to run. Here is about the smallest Flask API worth containerising. It answers one route with some JSON.

app.py
from flask import Flask

app = Flask(__name__)


@app.get("/")
def index():
    return {"status": "ok", "message": "Hello from a container"}

Alongside it sits a requirements.txt listing what the app needs to run. We include flask for the app itself and gunicorn, a production-grade server that will actually serve the app inside the container. The Flask development server is fine for local work, but gunicorn is what you want running in front of real traffic.

requirements.txt
flask
gunicorn

Those two files, app.py and requirements.txt, are everything the image needs to install dependencies and start the server. With them in place, we can write the Dockerfile that turns them into a runnable image.

Writing the Dockerfile

A Dockerfile is a plain text file named Dockerfile, with no extension, sitting in the root of your project. It is a recipe: a list of instructions Docker follows from top to bottom to assemble your image. Here is a complete, correct one for the app above.

Dockerfile
FROM python:3.12-slim

WORKDIR /app

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

COPY . .

EXPOSE 8000

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]

Eight lines, and every one earns its place. Taking them in order:

FROM python:3.12-slim chooses the base image, the foundation everything else is built on top of. This is a recent slim Python image: it already has Python installed on a trimmed-down Linux, so you are not starting from bare metal. The slim variant drops packages most apps never need, which keeps the final image smaller. You start from something that already runs Python rather than installing it yourself.

WORKDIR /app sets the working directory inside the image. Every command after this runs from /app, and it is created for you if it does not exist. It is the equivalent of cd /app, and it keeps your app files in one predictable place rather than scattered at the filesystem root.

COPY requirements.txt . copies just the requirements file from your project into the image, into the working directory. Notice that we copy this one file on its own, before the rest of the code. That ordering is deliberate, and the next section explains exactly why it matters.

RUN pip install --no-cache-dir -r requirements.txt installs the dependencies. RUN executes a command while the image is being built, so the installed packages become a baked-in part of the image rather than something downloaded at start-up. The --no-cache-dir flag tells pip not to keep its download cache, which would otherwise sit unused inside the image and make it larger.

COPY . . copies the rest of your project, including app.py, into the working directory. This comes after the install step on purpose, so your frequently changing source code does not invalidate the dependency layer above it.

EXPOSE 8000 documents that the container listens on port 8000. This line is mostly informational: it records the port the app uses but does not actually publish it. The real port mapping happens when you run the container, which we get to later.

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"] is the command that runs when the container starts. It launches gunicorn, binds it to port 8000 on all interfaces so traffic from outside the container can reach it, and points it at app:app, which means the app object inside app.py. Binding to 0.0.0.0 rather than 127.0.0.1 matters here, because a server listening only on localhost inside a container is unreachable from the host.

Why copy requirements before the code

The single most common beginner mistake is to copy everything in one COPY . . and install afterwards. It works, but it throws away the thing that makes Docker rebuilds fast. To see why, you have to know how Docker actually builds an image.

Each instruction in a Dockerfile produces a layer, and Docker caches those layers. When you rebuild, it walks down the file and reuses every cached layer until it hits an instruction whose inputs have changed. From that point on, every layer below is rebuilt from scratch. So the question is not whether layers are cached, but how often each one gets invalidated.

Your dependencies change rarely. Your source code changes constantly, every time you edit a line. If you copy the code before installing, then any code edit changes the COPY layer, which invalidates the install layer below it, and Docker reinstalls every dependency from scratch on a one-line change. By copying requirements.txt on its own first and installing it before COPY . ., the expensive install layer only rebuilds when the requirements file itself changes. Edit your app a hundred times and Docker reuses the cached dependencies every single time.

An image is built in layers

Each instruction in a Dockerfile is a cached layer, and a change to one layer invalidates every layer below it. The trick that makes rebuilds fast is ordering instructions from least frequently changed to most frequently changed. Dependencies near the top, your own source code near the bottom. Get that order right and most rebuilds reuse the slow steps instead of repeating them.

Add a .dockerignore

When Docker runs COPY . ., it copies everything in your project directory by default, and that is rarely what you want. A .dockerignore file, which works just like a .gitignore, tells Docker which files and folders to leave out. Create it in the project root.

.dockerignore
.venv/
__pycache__/
.git/
.env

Each line keeps something out of the image for a reason. .venv/ is your local virtual environment, which is specific to your machine and useless inside a Linux container that installs its own dependencies. __pycache__/ holds compiled Python files that Docker regenerates anyway. .git/ is your entire version history, often large and never needed at run time. Leaving these out keeps the image small and the build fast.

The last line, .env, is in a different category. It is there for safety, not size, and it points at the one mistake that can genuinely hurt you.

Baked-in secrets are leaked secrets

Never COPY a .env file or hardcode an API key into a Dockerfile. Image layers are readable, and anyone who can pull your image can inspect them. The signature of this mistake is subtle: a secret added in one layer and deleted in a later one is still recoverable from the earlier layer, so "I removed it afterwards" does not save you. The exposed secret lives in the image history forever. Pass secrets in as environment variables at run time instead, and keep .env out of the build with .dockerignore.

This is the same discipline you would apply outside Docker: secrets belong in the environment, not in the code or the artifact. If you have not set that up yet, our guide on storing API keys with a .env file covers the pattern that .dockerignore is protecting here.

Building the image

With the Dockerfile and .dockerignore in place, we can turn the recipe into an actual image. From the project root, run the build command.

Bash
docker build -t my-python-api .

Two parts of that command are worth understanding. The -t my-python-api flag tags the image with a name, so you can refer to it later as my-python-api instead of a long generated ID. The trailing . is the build context: it tells Docker which directory to send to the builder and treat as the root for COPY instructions. The dot means "this directory," which is why you run the command from your project root, where the Dockerfile lives. Docker reads the Dockerfile, runs each instruction, and leaves you with a finished image ready to run.

Running the container

An image is a static template. A container is that template brought to life and running. To start one from the image we just built:

Bash
docker run -p 8000:8000 my-python-api

The piece that does the real work is -p 8000:8000. It maps a port on your host to a port inside the container, in the form host:container. The number on the left is the port you open in a browser on your own machine; the number on the right is the port gunicorn listens on inside the container, the 8000 we bound to earlier. Without this mapping the container would happily serve requests internally while remaining completely unreachable from outside. With it in place, visiting http://localhost:8000 reaches your API.

This is also where secrets come in, the run-time way rather than the baked-in way. To pass configuration into the container, set environment variables when you run it. A single value uses -e, and a whole file of them uses --env-file.

Bash
docker run -p 8000:8000 -e API_KEY=your-key-here my-python-api

docker run -p 8000:8000 --env-file .env my-python-api

Both inject the values into the running container's environment, where your code reads them exactly as it would locally, without those values ever becoming part of the image. The image stays generic and shareable; the secrets stay outside it and are supplied fresh each time you run.

Where containers go from here

The payoff of all this is portability. The same image you built and ran locally is the same artifact you can run on any machine with Docker, push to a registry so others can pull it, or hand to a platform that runs containers for you. That is the whole point of packaging the environment alongside the code. For the next step of getting a Flask app onto the public internet, see our walkthrough on deploying a Flask app, and for the wider picture of how local work relates to a live deployment, local hosting versus deployment sets the context.

Frequently asked questions

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

An image is the packaged, immutable template: your application code, its dependencies, and the environment it runs in, built once and unchanging. A container is a running instance of that image. You build one image and can start many containers from it, the same way a class defines a blueprint and each object is a live instance of it. The image sits on disk doing nothing; the container is the process actually serving requests.

Why copy requirements.txt before the rest of the code in a Dockerfile?

It is about layer caching. Docker caches each instruction as a layer and reuses cached layers on rebuilds until something changes. Dependencies change rarely while source code changes constantly, so copying requirements.txt and installing it before copying the app means the slow install step is reused whenever only your code changed. Reinstalling dependencies happens only when the requirements file itself changes, which makes rebuilds dramatically faster.

How do I handle secrets in a Docker image?

Never bake them in. Image layers are readable, so an API key copied into an image is recoverable by anyone who pulls it, even if a later layer deletes the file. Instead, pass secrets as environment variables at run time, using -e KEY=value for a single value or --env-file .env for a whole file, and add .env to your .dockerignore so it never enters the build context.

Mastering APIs with Python

Packaging an API into an image is the step that takes your code from "runs on my laptop" to "runs anywhere." In the full book, containerisation is one link in a complete chain: real API clients built against live services, then deployed for real. 30 chapters, six portfolio projects covering Flask, OAuth, SQLite, Postgres, Docker, CI/CD, and AWS.

Get the book for €35

Chapter 3 is free to read.