How to Write a Dockerfile: Step-by-Step Tutorial with Best Practices 2025

Containerization has revolutionized how we build, ship, and deploy applications. At the heart of this transformation lies Docker, and learning how to write a Dockerfile is your gateway to mastering container technology. Whether you’re a developer just starting your DevOps journey or looking to solidify your containerization skills, this comprehensive Dockerfile tutorial will guide you through every step.

How Docker Works (Visual)

Dockerfile → Docker Build → Docker Image → Docker Run → Container

How Docker Works - How to write a Dockerfile - thedevopstooling.com
How Docker Works – How to write a Dockerfile – thedevopstooling.com

To dive deeper into how builds work, check the official Docker build overview

If you’re new to Docker, start with our beginner guide Docker for DevOps: Complete Introduction

What is a Dockerfile?

A Dockerfile is a plain text file containing a series of instructions that Docker uses to automatically build images. Think of it as a recipe that tells Docker exactly how to create your application environment, what files to include, and how to configure everything needed to run your application.

Unlike traditional deployment methods where you manually set up servers, a Dockerfile lets you define your entire application environment as code. This means you can version control your infrastructure, share it with your team, and recreate identical environments anywhere Docker runs.

See the full Dockerfile reference.

Why Dockerfiles Are Essential in DevOps

Understanding how to create a Dockerfile is crucial for modern DevOps practices for several key reasons:

Consistency Across Environments: Dockerfiles eliminate the “it works on my machine” problem by ensuring your application runs identically in development, testing, and production environments.

Version Control: Since Dockerfiles are plain text, you can track changes to your application environment alongside your code, making rollbacks and collaboration seamless.

Automation: Dockerfiles enable automated builds and deployments, reducing manual errors and accelerating your delivery pipeline.

Scalability: Container orchestration platforms like Kubernetes rely on Docker images built from Dockerfiles to scale applications efficiently.

Resource Efficiency: Containers created from well-optimized Dockerfiles use fewer resources than traditional virtual machines while providing similar isolation benefits.

Basic Structure of a Dockerfile

Before diving into our step-by-step Dockerfile example, let’s understand the fundamental building blocks. Every Dockerfile follows a similar pattern using specific instructions:

FROM: Specifies the base image to start from WORKDIR: Sets the working directory inside the container
COPY: Copies files from your local machine to the container RUN: Executes commands during the build process EXPOSE: Documents which ports the container will use CMD: Defines the default command to run when the container starts

These instructions form the backbone of any Dockerfile, and mastering them is essential for creating effective container images.

How to Write a Dockerfile Step by Step

Let’s walk through creating a complete Dockerfile for beginners using a practical Python web application example. This hands-on approach will help you understand each concept clearly.

Step 1: Set Up Your Project Structure

First, create a new directory for your project:

mkdir my-python-app
cd my-python-app

Create a simple Python application file called app.py:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return '<h1>Hello from Docker!</h1><p>Your Dockerfile is working perfectly!</p>'

@app.route('/health')
def health_check():
    return {'status': 'healthy', 'message': 'Application is running'}

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

Create a requirements.txt file to specify our Python dependencies:

Flask==2.3.3

Step 2: Create a .dockerignore File

Before writing your Dockerfile, create a .dockerignore file to exclude unnecessary files from your build context. This improves build performance and prevents sensitive files from accidentally being included:

__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
pip-log.txt
pip-delete-this-directory.txt
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git/
.mypy_cache/
.pytest_cache/
.hypothesis/
.DS_Store
README.md

Step 3: Write Your First Dockerfile

Now, create a file named Dockerfile (no extension) in your project directory:

# Use Python 3.9 slim image as base
FROM python:3.9-slim

# Create non-root user for security
RUN adduser --disabled-password --gecos '' appuser

# Set working directory in container
WORKDIR /app

# Copy requirements file first (for better caching)
COPY requirements.txt .

# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY app.py .

# Change ownership of app directory to appuser
RUN chown -R appuser:appuser /app

# Switch to non-root user
USER appuser

# Expose port 5000
EXPOSE 5000

# Set environment variable
ENV FLASK_APP=app.py

# Define the command to run the application
CMD ["python", "app.py"]

Let’s break down each instruction in this Dockerfile step by step:

FROM python:3.9-slim: This instruction tells Docker to use the official Python 3.9 slim image as our starting point. The slim variant is smaller than the full Python image while including everything we need.

RUN adduser –disabled-password –gecos ” appuser: Creates a non-root user called ‘appuser’ for security purposes. Running containers as root poses unnecessary security risks.

WORKDIR /app: Creates and sets /app as our working directory inside the container. All subsequent commands will run from this directory.

COPY requirements.txt .: Copies our requirements file to the current directory in the container (which is /app). We copy this first to take advantage of Docker’s layer caching.

RUN pip install –no-cache-dir -r requirements.txt: Installs our Python dependencies. The --no-cache-dir flag prevents pip from storing cache files, keeping our image smaller.

COPY app.py .: Copies our application code to the container.

RUN chown -R appuser:appuser /app: Changes ownership of the app directory to our non-root user.

USER appuser: Switches to the non-root user for all subsequent operations.

EXPOSE 5000: Documents that our application will listen on port 5000. This doesn’t actually open the port but serves as documentation.

ENV FLASK_APP=app.py: Sets an environment variable that Flask uses to locate our application.

CMD [“python”, “app.py”]: Specifies the command to run when the container starts. This uses the exec form, which is recommended.

Step 4: Build Your Docker Image

To build your Docker image from the Dockerfile, use the docker build command:

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

The -t flag tags your image with a name and version, and the . tells Docker to look for the Dockerfile in the current directory. You’ll see output showing each step being executed:

Sending build context to Docker daemon  4.096kB
Step 1/8 : FROM python:3.9-slim
 ---> 2f7d5a5d3b18
Step 2/8 : WORKDIR /app
 ---> Running in 8a2c9d1e4b3f
 ---> 7c1a8d2e5f9b
Step 3/8 : COPY requirements.txt .
 ---> 3e5f7d9c2a8b
...
Successfully built 9a7c3e5f1d2b
Successfully tagged my-python-app:latest

Step 5: Run Your Container

Once your image is built, create and run a container from it:

docker run -p 8080:5000 my-python-app:latest

The -p 8080:5000 flag maps port 8080 on your host machine to port 5000 inside the container. Now you can visit http://localhost:8080 in your browser to see your application running!

We’re using the official Python base image from Docker Hub here

You might also like Docker Commands Cheat Sheet to quickly practice the commands used here.

Common Dockerfile Instructions Reference

Here’s a comprehensive table of the most important Dockerfile instructions every beginner should know:

InstructionPurposeExampleNotes
FROMSets base imageFROM ubuntu:20.04Must be first instruction (except ARG)
WORKDIRSets working directoryWORKDIR /appCreates directory if it doesn’t exist
COPYCopies files from host to containerCOPY . /appPreferred over ADD for simple copying
ADDCopies files with extra featuresADD file.tar.gz /appCan extract archives and fetch URLs
RUNExecutes commands during buildRUN apt-get updateCreates new layer in image
CMDDefault command when container startsCMD ["python", "app.py"]Can be overridden at runtime
ENTRYPOINTConfigures container executableENTRYPOINT ["python"]Cannot be overridden easily
EXPOSEDocuments port usageEXPOSE 8080For documentation only
ENVSets environment variablesENV NODE_ENV=productionAvailable at build and runtime
ARGBuild-time variablesARG VERSION=1.0Only available during build
VOLUMECreates mount pointsVOLUME ["/data"]For persistent data storage
USERSets user contextUSER nodejsFor security best practices

Debugging Common Dockerfile Mistakes

Even experienced developers make mistakes when writing Dockerfiles. Here are the most common issues beginners encounter and how to fix them:

Issue 1: Build Context Too Large

Problem: Your build takes forever because Docker is sending too many files as build context.

Solution: Create a .dockerignore file to exclude unnecessary files:

node_modules/
*.log
.git/
*.md
.env
__pycache__/

Issue 2: Inefficient Layer Caching

Problem: Small changes to your code cause Docker to rebuild everything.

Solution: Copy dependency files before copying source code:

# Good: Dependencies are cached separately
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

# Bad: Code changes invalidate dependency cache
COPY . .
RUN pip install -r requirements.txt

Issue 3: Running Containers as Root

Problem: Security vulnerabilities from running processes as root user.

Solution: Create and use a non-root user:

RUN adduser --disabled-password --gecos '' appuser
USER appuser

Issue 4: Forgetting to Expose Ports

Problem: Application runs but isn’t accessible from outside the container.

Solution: Use EXPOSE instruction and proper port mapping:

EXPOSE 3000

docker run -p 3000:3000 myapp

Docker Security Best Practices for Beginners

Security should be a primary concern when containerizing applications. Here are essential security practices to implement from day one:

Protecting Sensitive Information

Never include secrets in your Dockerfile or image. Avoid embedding API keys, passwords, or certificates directly in your containers:

# Bad - Never do this
ENV DATABASE_PASSWORD=mysecretpassword
COPY api-keys.json /app/

# Good - Use environment variables at runtime
ENV DATABASE_PASSWORD=""

Instead, pass sensitive information using environment variables or mount secrets at runtime:

docker run -e DATABASE_PASSWORD=mysecret myapp

Want to go further? Optimize Docker Image Size: Best Practices

Regular Security Updates

Keep your base images updated to receive the latest security patches:

# Specify exact versions for reproducibility
FROM python:3.9.18-slim

# Update package lists and install security updates
RUN apt-get update &amp;&amp; apt-get upgrade -y &amp;&amp; apt-get clean

Using Security Scanning Tools

Before deploying to production, scan your images for vulnerabilities:

# Using Docker Scout (built into Docker Desktop)
docker scout quickview my-python-app:latest

# Using Trivy
trivy image my-python-app:latest

Alternative Base Images for Different Use Cases

While official images like python:3.9-slim work well for beginners, consider these alternatives for specific scenarios:

Distroless Images

Google’s distroless images contain only your application and runtime dependencies, reducing attack surface:

# Multi-stage build with distroless
FROM python:3.9-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt

FROM gcr.io/distroless/python3
COPY --from=builder /root/.local /root/.local
COPY app.py /app/
WORKDIR /app
CMD ["python", "app.py"]

Scratch Images

For truly minimal images (typically used with compiled languages):

FROM scratch
COPY myapp /
CMD ["/myapp"]

Alpine Linux

Alpine-based images are smaller and security-focused, but require careful consideration:

# For even smaller images, consider:
FROM python:3.9-alpine
# Note: Alpine uses musl libc which may cause compatibility issues with some packages
# Alpine uses apk instead of apt-get
RUN apk add --no-cache gcc musl-dev

Important: Alpine Linux uses musl libc instead of glibc, which can cause compatibility issues with some Python packages that include compiled extensions. Test thoroughly before using Alpine in production.

Troubleshooting Common Runtime Issues

Even with a perfectly written Dockerfile, you might encounter runtime problems. Here’s how to diagnose and fix common issues:

Port Conflicts

Problem: “Port already in use” error when running your container.

Solution: Check what’s using the port and choose a different host port:

# Check what's using port 8080
lsof -i :8080

# Use a different host port
docker run -p 8081:5000 my-python-app:latest

Permission Errors

Problem: Application can’t write files or access directories.

Solution: Ensure proper file ownership and permissions:

# Create directory with correct permissions
RUN mkdir -p /app/data &amp;&amp; chown appuser:appuser /app/data

# Or use COPY with ownership
COPY --chown=appuser:appuser . /app/

Container Exits Immediately

Problem: Container starts but exits right away.

Solution: Check the logs and ensure your CMD is correct:

# View container logs
docker logs container_name

# Run container interactively to debug
docker run -it my-python-app:latest /bin/bash

Environment Variable Issues

Problem: Application can’t find required environment variables.

Solution: Verify environment variables are set correctly:

# Set default values in Dockerfile
ENV FLASK_ENV=production
ENV DEBUG=false

# Or pass at runtime
docker run -e FLASK_ENV=development my-python-app:latest

Dockerfile Best Practices for DevOps

Following these best practices will help you create efficient, secure, and maintainable Dockerfiles from the start:

1. Use Official Base Images

Always start with official images from Docker Hub when possible. They’re regularly updated, secure, and well-maintained:

# Good
FROM node:18-alpine

# Avoid
FROM custom-node-image

2. Optimize Layer Ordering

Put instructions that change frequently (like copying source code) toward the end of your Dockerfile:

FROM python:3.9-slim
WORKDIR /app

# Install system dependencies (changes rarely)
RUN apt-get update &amp;&amp; apt-get install -y curl

# Install Python dependencies (changes sometimes)
COPY requirements.txt .
RUN pip install -r requirements.txt

# Copy source code (changes frequently)
COPY . .

3. Use Multi-stage Builds for Production

For larger applications, use multi-stage builds to create smaller final images:

# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Production stage
FROM node:18-alpine AS production
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
CMD ["npm", "start"]

4. Minimize Layers

Combine related RUN commands to reduce layer count:

# Good
RUN apt-get update &amp;&amp; \
    apt-get install -y curl wget &amp;&amp; \
    apt-get clean

# Avoid
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y wget

5. Set Appropriate Health Checks

Include health checks to help orchestration platforms like Kubernetes and Docker Swarm manage your containers effectively. These platforms use health checks to determine when containers are ready to receive traffic and when they need to be restarted:

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:5000/health || exit 1

Frequently Asked Questions

What is the simplest Dockerfile example?

The simplest Dockerfile contains just a FROM instruction and a CMD instruction:

FROM alpine:latest
CMD ["echo", "Hello World"]

This creates a container that prints “Hello World” and exits. While minimal, it demonstrates the basic Dockerfile structure.

How do I run a Dockerfile?

You don’t run a Dockerfile directly. Instead, you:

  1. Build an image from the Dockerfile: docker build -t myapp .
  2. Run a container from the image: docker run myapp

The Dockerfile is like a blueprint that Docker uses to create the actual runnable container image.

What is CMD vs ENTRYPOINT in Dockerfile?

CMD provides default arguments that can be overridden when running the container:

CMD ["python", "app.py"]
# Can be overridden: docker run myapp python test.py

ENTRYPOINT defines the main command that always runs:

ENTRYPOINT ["python"]
CMD ["app.py"]
# Always runs python, but you can change the script: docker run myapp test.py

Where should I put a Dockerfile?

Place your Dockerfile in the root directory of your project, alongside your application code. This location allows Docker to access all necessary files during the build process while keeping the build context minimal.

Can I use multiple FROM statements in one Dockerfile?

Yes! This creates a multi-stage build, which is excellent for creating smaller production images:

FROM node:18 AS builder
# Build steps here

FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html

How do I copy files with specific permissions?

Use the --chown flag with COPY or ADD instructions:

COPY --chown=1000:1000 app.py /app/

This copies the file and sets the owner to user ID 1000 and group ID 1000.

Conclusion

Learning how to write a Dockerfile is a fundamental skill for any developer working with modern applications. Throughout this tutorial, you’ve learned the essential concepts, from understanding what a Dockerfile is to writing your first complete example, building images, following security best practices, and troubleshooting common issues.

Remember that mastering Dockerfiles takes practice. Start with simple applications like the Python example we created, then gradually work with more complex projects. Each Dockerfile you write will teach you something new about containerization and help you build more efficient, secure applications.

The key to success with Docker is experimentation. Don’t be afraid to try different base images, optimize your layers, and explore advanced features like multi-stage builds. As you become more comfortable with single containers, consider learning Docker Compose for managing multi-container applications – it’s the natural next step in your containerization journey. As you advance further, explore building multi-architecture images that work on both AMD64 and ARM processors using Docker Buildx for broader deployment compatibility.

Ready to put your new skills to work? Try writing a Dockerfile for your current project. Start simple, focus on getting it working, then optimize for production.

What to Learn Next:

  • Master Docker Commands: Check out our comprehensive Docker Commands Cheat Sheet to level up your container management skills
  • Optimize Your Images: Learn advanced techniques in our Docker Image Optimization Guide
  • Scale with Orchestration: Explore Kubernetes fundamentals for production container orchestration
  • DevOps Integration: Discover how to integrate Docker into your CI/CD pipeline with our Docker for DevOps guide

Have questions about your Dockerfile or run into issues? Drop them in the comments below – we’re here to help you succeed in your containerization journey!

More Docker Resources: Master Containerization and Image Optimization

Similar Posts

Leave a Reply