Skip to main content

Docker Python Basics: Writing Your First Dockerfile

A Dockerfile is a text file with a sequence of instructions that tell Docker how to build an image. Each instruction creates a new layer in the image; when you run docker build, Docker executes them in order and caches the result. Writing a Dockerfile is straightforward once you understand the core commands: FROM, RUN, COPY, WORKDIR, and CMD. This article walks you through every instruction with examples so that you can write your first Dockerfile in minutes.

When I first learned Dockerfiles, I memorized instructions and cargo-culted examples. The breakthrough came when I realized: a Dockerfile is just a script that documents exactly how to build your runtime. Every line is something you might type into a shell to set up a fresh Linux box. That mental model makes Dockerfile syntax intuitive.

The Core Dockerfile Instructions

A minimal Dockerfile has four parts: choose a base image, install dependencies, copy your code, and specify the startup command.

FROM — Choose a base image

Every Dockerfile starts with FROM, which selects a base image to build on top of. You're not building Linux from source; you're starting with a pre-made image containing a working OS and runtime.

FROM python:3.11-slim

This line says: "Start with the official Python 3.11 image, specifically the 'slim' variant." Python base images from the official library (python:3.11, python:3.11-slim, python:3.11-alpine) come with Python pre-installed and a minimal Linux system. The -slim variant is smaller than python:3.11 (300 MB vs 900 MB) and good for most production apps. You'll learn about choosing base images in depth in article 4.

WORKDIR — Set the working directory

WORKDIR creates a directory and makes it the current directory for all subsequent commands (like cd in a shell).

WORKDIR /app

This is a best practice. It keeps your app's files organized and makes the Dockerfile easier to follow. If you don't set a WORKDIR, files end up scattered in the image root.

COPY — Add files from your machine to the image

COPY takes files from your build context (your machine) and places them inside the image.

COPY requirements.txt .
COPY . .

The first line copies requirements.txt from your current directory to /app/ (the WORKDIR). The second copies everything else (your Python source code, static files, etc.). We copy requirements.txt separately so that we can take advantage of layer caching—explained fully in article 3.

RUN — Execute shell commands

RUN runs a shell command (like bash -c) inside the image. This is where you install packages, compile code, or set up the runtime.

RUN pip install -r requirements.txt

This installs all Python packages listed in requirements.txt. Since we're using a Python image, pip is already available.

CMD — Specify the default startup command

CMD defines what runs when you docker run the image.

CMD ["python", "app.py"]

This tells Docker to run python app.py when the container starts. Note the JSON array syntax: ["executable", "arg1", "arg2"]. This is the preferred form (exec form), which avoids shell interpretation issues.

Your First Complete Dockerfile

Here's a real, working Dockerfile for a FastAPI application:

FROM python:3.11-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"]

Let's walk through this line by line:

  1. FROM python:3.11-slim — Start with Python 3.11 on a minimal Linux base.
  2. WORKDIR /app — Create and enter /app inside the image.
  3. COPY requirements.txt . — Copy your dependencies list from your machine to /app/.
  4. RUN pip install --no-cache-dir -r requirements.txt — Install packages. The --no-cache-dir flag saves disk space by not storing pip's cache (you won't reinstall inside the running container, so the cache is wasted).
  5. COPY . . — Copy your entire project (main.py, templates/, static/, etc.) to /app/.
  6. EXPOSE 8000 — Document that your app listens on port 8000 (this is informational; it doesn't actually publish the port).
  7. CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] — When the container starts, run uvicorn.

Your requirements.txt might look like this:

fastapi==0.109.0
uvicorn==0.27.0
pydantic==2.5.0

And your main.py:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
return {"message": "Hello from Docker"}

@app.get("/health")
def health_check():
return {"status": "ok"}

Building and Running Your Image

Once you have a Dockerfile, build it:

docker build -t myapp:1.0 .

The -t flag tags the image with a name and version. The . means "use the Dockerfile in the current directory." Docker outputs each step as it builds:

Step 1/7 : FROM python:3.11-slim
Step 2/7 : WORKDIR /app
Step 3/7 : COPY requirements.txt .
Step 4/7 : RUN pip install --no-cache-dir -r requirements.txt
Step 5/7 : COPY . .
Step 6/7 : EXPOSE 8000
Step 7/7 : CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
Successfully built abc123def456
Successfully tagged myapp:1.0

Then run a container from the image:

docker run -p 8000:8000 myapp:1.0

The -p 8000:8000 flag maps port 8000 from inside the container to port 8000 on your machine. Open http://localhost:8000/docs in your browser and you'll see the FastAPI Swagger UI. Your app is running in a container!

Common Dockerfile Pitfalls

Forgetting to specify a WORKDIR. Without it, files get scattered at the root (/), making the image messy and harder to debug. Always set WORKDIR.

Copying the entire project before installing dependencies. A common pattern is:

COPY . .
RUN pip install -r requirements.txt

This is inefficient. If you change one line of code, Docker rebuilds the entire layer, including reinstalling packages (5+ minutes). The correct order is to COPY requirements.txt first, then RUN pip install, then COPY the code. That way, unchanged requirements.txt lets Docker reuse the cached pip install layer.

Using latest base image tag. FROM python:latest seems convenient but means your Dockerfile builds differently each month as new Python versions release. Use explicit versions like python:3.11-slim.

Forgetting to expose ports. The EXPOSE instruction is not strictly necessary, but it documents which ports your app needs. Without it, other developers (or your future self) have to read the source code to figure out what port to map.

Key Takeaways

  • A Dockerfile is a text file with instructions to build a Docker image.
  • Core instructions: FROM (base image), WORKDIR (directory), COPY (add files), RUN (execute commands), CMD (startup).
  • Always COPY requirements.txt separately before RUN pip install to leverage layer caching (huge speed improvement).
  • Build with docker build -t name:tag .; run with docker run -p host:container name:tag.
  • Explicit base image versions (python:3.11-slim) are safer than latest tags.

Frequently Asked Questions

What is the difference between RUN and CMD?

RUN executes a command during the build process and commits the result to the image. CMD specifies the command to run when a container starts from the image. You can have multiple RUN instructions but only one CMD.

Can I use a shell in Dockerfile?

Yes. RUN ["bash", "-c", "pip install -r requirements.txt && python setup.py"] uses bash. The exec form (JSON array) is preferred because it avoids shell interpretation issues, but shell form (RUN pip install ...) works too.

What does EXPOSE actually do?

EXPOSE documents which ports your app expects, but doesn't actually publish them. You still need -p when running the container. It's a best-practice annotation.

Can I run multiple commands with RUN?

Yes. Chain them: RUN apt-get update && apt-get install -y curl && pip install requests. This creates a single layer, which is more efficient than three separate RUN instructions.

How do I pass environment variables to a Dockerfile?

Use ENV KEY=value. For example, ENV PYTHONUNBUFFERED=1 tells Python to output logs immediately instead of buffering them.

Further Reading