# 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`](http://app.py) file as the entry point and a `requirements.txt` for dependencies:

```dockerfile
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`](http://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 using `latest`. This ensures consistency and avoids unexpected behavior due to upstream changes.
    
    ```dockerfile
    # 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:
        
* ```dockerfile
      # 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  
    Using `latest` 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](https://docs.docker.com/build/building/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 the `ENV` 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.
    
    ```dockerfile
    # 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.
    
    ```dockerfile
    # 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 the `HEALTHCHECK` instruction to define how Docker should check if your container is still healthy. This is useful for monitoring and restarting unhealthy containers automatically.
    
    ```dockerfile
    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 sure `curl` 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:

```dockerfile
# 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](https://docs.docker.com/get-started/docker-concepts/building-images/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 the `root` 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 the `USER` instruction:
    
    ```dockerfile
    # 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](https://github.com/aquasecurity/trivy) or [Snyk](https://snyk.io/) 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 ba[se im](https://github.com/aquasecurity/trivy)age[s (e](https://snyk.io/).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:
            

```bash
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](https://github.com/docker-library/docs) — 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.
