Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes

Build a Custom Docker Image

Learn to build a custom Docker image from a Dockerfile using best practices. This lesson covers the build process, optimization tips, and common pitfalls.

Focus: build a custom docker image

Sponsored

You have a perfect Python application — tested, reviewed, and ready to deploy. But every time you hand it to a colleague or push it to a server, something breaks. A missing dependency, a different Python version, an environment variable you forgot to set. The root cause? Your app runs in a bespoke, undocumented environment. Building a custom Docker image solves this once and for all: you script the entire environment — OS, libraries, runtime, code — into a single, portable artifact. No more "it works on my machine." This lesson teaches you to build a custom Docker image from a simple Python app using a Dockerfile, so your environment becomes code.

The problem this lesson solves

Shipping a modern Python app involves more than just code. You need the right base OS, the correct Python version, every pip dependency pinned, and all runtime config wired up. Without a custom Docker image, you rely on manual setup or brittle shell scripts. A single missed dependency or a change in the base system can crash production.

Building a custom Docker image turns your entire runtime environment into a version-controlled, immutable artifact. This lesson solves the pain of environment drift between dev, staging, and production and makes your deployments predictable and repeatable.

Why now? When you share an app with a teammate, they shouldn't spend an hour installing Postgres dev headers. With a custom image, they docker pull and run instantly. This skill is foundational for every Docker user.

Core concept / mental model

Think of a Docker image as a frozen, layered filesystem snapshot plus metadata for execution. A Dockerfile is the recipe that builds that snapshot — instruction by instruction.

Concept Analogy
FROM Choose your base OS (like Ubuntu 22.04)
RUN Run a command inside that OS (like installing Python)
COPY Add your source files into the image
CMD Define what happens when you docker run the image

The mental model: Each instruction in a Dockerfile creates a new layer. Layers are cached and reused. If you change a line, only layers after that line are rebuilt. This caching makes rebuilds fast — but also makes ordering critical.

Key definitions: - Base image: The starting layer (e.g., python:3.11-slim) - Intermediate container: A temporary container created during build to execute each RUN instruction - Image layer: The read-only filesystem diff saved after each instruction - Custom image: Any image you build yourself, derived from a base but adding your own layers

How it works step by step

1. Write a Dockerfile

Create a file named Dockerfile (no extension) in your project root. It starts with a FROM instruction:

FROM python:3.11-slim

2. Set up a working directory

Inside the image, your app lives in a dedicated folder. Use WORKDIR to set it:

WORKDIR /app

3. Install dependencies

Copy your requirements.txt first to leverage caching. Then run pip install:

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

4. Copy the application code

COPY . .

5. Set environment variables

Use ENV for runtime configuration:

ENV PYTHONUNBUFFERED=1
ENV FLASK_ENV=production

6. Define the startup command

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

7. Build the image

docker build -t my-python-app:1.0 .

Pro tip: The . at the end tells Docker to use the current directory as the build context. Only files inside the context are available to COPY.

8. Run a container from your custom image

docker run --name my-app-instance -p 5000:5000 my-python-app:1.0

Hands-on walkthrough

Let's build a real custom Docker image for a Flask web server. Assume your project has this structure:

my-flask-app/
├── app.py
├── requirements.txt
└── Dockerfile

app.py:

from flask import Flask
import os

app = Flask(__name__)

@app.route('/')
def home():
    return f"Hello from Docker! Running Python {os.environ.get('PYTHON_VERSION', '?')}"

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

requirements.txt:

flask==2.3.3

Dockerfile:

# 1. Base image
FROM python:3.11-slim

# 2. Set working directory
WORKDIR /app

# 3. Copy requirements and install deps
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# 4. Copy app source
COPY . .

# 5. Runtime config
ENV PYTHONUNBUFFERED=1 \
    PYTHON_VERSION=3.11

# 6. Command
EXPOSE 5000
CMD ["python", "app.py"]

Now build and run:

cd my-flask-app
docker build -t flask-app:v1 .

# Output wire:
# Step 1/7 : FROM python:3.11-slim
# ---> 1f5e6a7b8c9d
# Step 2/7 : WORKDIR /app
# ---> Using cache
# ...
# Successfully built a1b2c3d4e5f6
# Successfully tagged flask-app:v1

docker run -d -p 8080:5000 flask-app:v1

Check it: curl http://localhost:8080 should return "Hello from Docker!..."

Expected output

$ curl http://localhost:8080
Hello from Docker! Running Python 3.11

Compare options / when to choose what

Approach Use case Pros Cons
Single-stage Dockerfile (above) Simple apps, small teams Easy to write, one artifact Larger image size, includes build tools
Multi-stage build Production apps, minimal size Separate build and runtime stages, tiny final image More complex Dockerfile
Docker Compose Multi-service apps Orchestrates multiple images, volumes, networks Requires Compose file, not for single services

When to choose what: - Use a single-stage for learning, prototyping, or internal tools - Use multi-stage for public images or production where image size matters (reduces from 900MB to 120MB) - Use Compose when your app needs a database, cache, or other services alongside

Troubleshooting & edge cases

Build fails because of missing dependencies

Your Python package might require a system library (e.g., libpq-dev for psycopg2). Install them before pip:

RUN apt-get update && apt-get install -y libpq-dev && rm -rf /var/lib/apt/lists/*

COPY . . is slow or includes unwanted files

Create a .dockerignore file:

__pycache__/
*.pyc
.git
.env
venv/

Image is too large (900MB+)

Use a slim or alpine base image like python:3.11-slim or adopt multi-stage builds.

Build fails with "docker: command not found"

Install Docker: sudo apt install docker.io (Linux) or download from docker.com

Permission denied connecting to Docker daemon

Add your user to the docker group: sudo usermod -aG docker $USER then log out and back in.

What you learned & what's next

You now know how to build a custom Docker image from a Python app. You learned:

  • The anatomy of a Dockerfile: FROM, WORKDIR, COPY, RUN, CMD
  • Caching and layer ordering: put the things that change least often first
  • How to build (docker build) and tag an image
  • How to troubleshoot common issues like missing dependencies and large images

What's next:

Now that you can build custom images, the next lesson teaches optimizing image size with multi-stage builds — a critical skill for production images that are faster to pull and deploy.

Recap practice: Try extending this lesson's Flask app to show the current time. Change app.py, rebuild the image with a new tag, and run it. Verify the output.

Practice recap

Now take the Flask app from this lesson and extend it: add a second endpoint that returns a JSON payload. Update app.py, rebuild with a new tag (e.g., flask-app:v2), and run it. Use docker images to see both versions and verify that the layering reused cached layers for everything except the final COPY. This hands-on practice reinforces layer caching and version tagging.

Common mistakes

  • Forgetting to create a .dockerignore file — you end up copying cache, venv, or git files into the image, bloating it and even breaking the build.
  • Installing Python dependencies without pinning versions in requirements.txt — a minor release change can break your app silently.
  • Putting COPY . . before RUN pip install — this breaks Docker's layer caching, forcing a full reinstall on every code change.
  • Using a CMD that runs in shell mode like CMD python app.py instead of exec form CMD ["python", "app.py"] — shell mode can cause signal handling issues.

Variations

  1. Use python:alpine for minimal image size (under 50MB) but watch for missing glibc compatibility.
  2. Write a multi-stage Dockerfile to separate build tools (pip, gcc) from runtime, reducing final image size by 70-90%.
  3. Use docker init to generate a starter Dockerfile for your project interactively.

Real-world use cases

  • Package a Flask or FastAPI microservice into a versioned Docker image for CI/CD deployment to Kubernetes.
  • Create a reproducible data-science environment with pinned Python, scikit-learn, and Jupyter for team collaboration.
  • Build a custom runtime image for a legacy app that requires specific glibc or system libraries not in official images.

Key takeaways

  • A Dockerfile converts your app's environment into code — from base OS to startup command.
  • Layer ordering matters: put infrequently changing instructions (base image, dependencies) early for maximum cache reuse.
  • Use .dockerignore to exclude unnecessary files from the build context and reduce image size.
  • Slim base images (like python:3.11-slim) are the sweet spot between size and compatibility for most Python apps.
  • Always use exec form for CMD and ENTRYPOINT to ensure proper signal handling in containers.
  • You can verify a built image with docker history <image-name> to inspect all layers.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.