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
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 pulland 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 toCOPY.
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:8080should 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
.dockerignorefile — 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 . .beforeRUN pip install— this breaks Docker's layer caching, forcing a full reinstall on every code change. - Using a
CMDthat runs in shell mode likeCMD python app.pyinstead of exec formCMD ["python", "app.py"]— shell mode can cause signal handling issues.
Variations
- Use
python:alpinefor minimal image size (under 50MB) but watch for missing glibc compatibility. - Write a multi-stage Dockerfile to separate build tools (pip, gcc) from runtime, reducing final image size by 70-90%.
- Use
docker initto 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
.dockerignoreto 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.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.