Dockerfiles 101: A Practical Guide to Building Efficient Images


Introduction
A Dockerfile is a simple text file with a list of instructions used to build a Docker image — the foundation for creating containerized applications. It automates everything from the base OS to app code, dependencies, and runtime setup, making your environments consistent and repeatable.
This setup is crucial for modern development. With Dockerfiles, you eliminate the classic "it works on my machine" issue by standardizing how your app is built and run — locally, in CI/CD pipelines, and in production.
Here’s where Dockerfiles shine:
Local Development: Spin up production-like environments with ease.
CI/CD Pipelines: Automate image builds and deployments for faster, safer shipping.
Production Deployment: Run apps reliably across any environment using the same image every time.
Basic Dockerfile Anatomy
A Dockerfile is made up of simple, declarative instructions that are executed in order to build a Docker image. Each instruction creates a new image layer, forming the final structure of your container. Here are some of the most commonly used instructions and what they do:
Instruction | Description |
FROM | Sets the base image for the build (e.g., FROM python:3.9 ). It’s the starting point and must be the first instruction (excluding comments). |
RUN | Executes a command at build time (e.g., RUN pip install -r requirements.txt ). Typically used to install packages or modify the image. |
COPY | Copies files or directories from your host into the image (e.g., COPY . /app ). |
CMD | Specifies the default command to run when the container starts (e.g., CMD ["python", "app.py"] ). Only the last CMD is used. |
ADD | Like COPY , but can also handle remote URLs and extract .tar files (e.g., ADD file.tar.gz /app ). |
ENTRYPOINT | Defines the main command that always runs in the container (e.g., ENTRYPOINT ["python"] ). Can be combined with CMD to pass default arguments. |
EXPOSE | Documents the port the container will listen on (e.g., EXPOSE 5000 ). This doesn't publish the port — that’s done with -p when running the container. |
Sample Minimal Dockerfile for a Python App
Here’s a minimal Dockerfile for a Python app using Flask, assuming you have an app.py
file as the entry point and a requirements.txt
for dependencies:
FROM python:3.9
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
This Dockerfile:
Uses
python:3.9
as a base image.Sets
/app
as the working directory.Copies and installs dependencies from
requirements.txt
.Copies the application code from your local directory to the working directory
/app
.Runs
app.py
when the container starts.
Best Practices
Following best practices when writing Dockerfiles helps you create images that are efficient, secure, and easy to maintain. Here are some key guidelines recommended by the Docker community:
Pin Base Image Versions
Always specify an exact version for your base image (e.g.,python:3.9.5-slim
) instead of usinglatest
. This ensures consistency and avoids unexpected behavior due to upstream changes.# Avoid FROM python:latest # Prefer FROM python:3.9.5-slim
Use
.dockerignore
Create a.dockerignore
file to leave out unnecessary files like.git
,node_modules
,.pyc
, and other temp files from your build context. This helps shrink your image size and speeds up build times.Combine RUN Commands: Each
RUN
instruction creates a new layer, increasing image size. Combine commands using&&
or multi-line syntax to minimize layers. For example:
# Avoid: RUN apt-get update RUN apt-get install -y wget # Prefer: RUN apt-get update && apt-get install -y wget
Avoid the
latest
Tag
Usinglatest
might seem convenient, but it can introduce unexpected changes as the image gets updated over time. Always pin your base image to a specific version to ensure consistent and predictable builds.
These practices, based on Docker’s official best practices, help you build leaner, more secure, and more reliable images.
Intermediate Tips
Once you’ve got the basics down, these intermediate techniques can help you take your Dockerfiles to the next level:
Set Environment Variables
Use theENV
instruction to define environment variables that are accessible at runtime. It’s a clean way to configure your app without hardcoding values into your codebase.# Set Flask environment mode ENV FLASK_ENV=production # Disable Python .pyc bytecode file generation ENV PYTHONDONTWRITEBYTECODE=1 # Example API key placeholder ENV API_KEY=your-api-key-here
Caching and Layer Optimization
Docker caches image layers to speed up rebuilds. To take advantage of this, order your instructions from least to most frequently changing. For example, copy and install dependencies before adding your full application code — that way, Docker can reuse the cached layers if your app code changes but your dependencies don't.# Copy and install dependencies first COPY requirements.txt . RUN pip install -r requirements.txt # Then copy the rest of the app COPY . .
Add Health Checks
Use theHEALTHCHECK
instruction to define how Docker should check if your container is still healthy. This is useful for monitoring and restarting unhealthy containers automatically.HEALTHCHECK --interval=5m --timeout=5s \ CMD curl -f http://localhost:5001/health || exit 1
In this example, Docker pings a Flask app’s
/health
endpoint every 5 minutes. If the check fails, the container is marked as unhealthy. Additionally, make surecurl
is installed in your image for this to work.
đź§ŞMulti-stage Builds
Multi-stage builds let you use multiple FROM
statements to define separate stages in your Dockerfile — typically one for building and another for running your app. This helps keep your final image clean and lightweight by copying only what’s needed into the runtime stage.
It’s a great way to reduce image size, boost security, and streamline builds — especially for Python and Flask apps.
Here’s an example for a Flask app:
# Build stage
FROM python:3.9-slim AS builder
# Set working directory in the image
WORKDIR /app
# Copy only the dependency file to leverage Docker layer caching
COPY requirements.txt .
# Install dependencies to a custom location to keep the runtime image clean
RUN pip install --prefix=/install -r requirements.txt
# Runtime stage: Copy only what's needed to run the app
FROM python:3.9-slim
# Set the same working directory
WORKDIR /app
# Copy the installed packages from the builder stage
COPY --from=builder /install /usr/local
# Copy the rest of the application code
COPY . .
# Define the default command to run your app
CMD ["python3", "docker-entrypoint.py"]
In this Dockerfile:
The build stage uses
python:3.9-slim
to install dependencies in an isolated directory.The runtime stage also uses
python:3.9-slim
, but only copies in the installed packages and app code — leaving behind build tools and temp files.The result is a smaller, cleaner image that’s easier to ship and more secure.
While multi-stage builds are often used in compiled languages like Go or Java, they can also streamline Python apps by keeping only what’s needed in the final image. This approach aligns with Docker’s official guidance on multi-stage builds.
Security Considerations
Security is a key part of working with containers. Following a few simple practices can significantly reduce the risk of vulnerabilities in your Docker images.
Use a Non-root User
By default, containers run as theroot
user, which can be dangerous if the container is ever compromised. To limit permissions, create a dedicated non-root user and switch to it using theUSER
instruction:# Create a non-root user RUN useradd -m appuser # Switch to the non-root user USER appuser
This limits the container’s permissions, reducing potential damage.
Scan Images for Vulnerabilities
Use tools like Trivy or Snyk to scan your images for known vulnerabilities in OS packages and dependencies. Add scans to your CI pipeline to catch issues early.Keep Images Small and Minimal
Use slim or minimal base images (e.g.,python:3.9-slim
) and avoid installing unnecessary tools. Smaller images are not only faster to build and ship — they also reduce your attack surface.Clean Up After Installations
After installing packages, remove cache and temporary files to avoid bloating your image:
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
Wrapping up
In this guide, we walked through the essentials of writing effective Dockerfiles — from basic instructions and best practices to intermediate optimizations, multi-stage builds, and security hardening tips.
By following these patterns, you can build Docker images that are clean, reliable, and production-ready — whether you're spinning up a local dev environment or deploying at scale.
For more inspiration, check out the official Docker samples on GitHub — they showcase Dockerfiles for a wide range of real-world applications.
Have a tip, question, or trick you use in your own Dockerfiles? Drop it in the comments — let’s learn together.
Subscribe to my newsletter
Read articles from Manny directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Manny
Manny
Helping developers and engineers grow through cloud tutorials, backend projects, and honest tech reviews.