A Dockerfile is a text file with instructions that tell Docker how to build your image. Every Docker image — from nginx to postgres to your own app — starts with a Dockerfile.
In this tutorial, you will learn every key Dockerfile instruction and write a complete, production-ready Dockerfile.
Dockerfile Basics
A Dockerfile is a plain text file named Dockerfile (no extension). Docker reads it top to bottom when building an image. Each instruction creates a new layer.
Here is a simple Dockerfile for a Python web app:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "app.py"]
Build this into an image:
docker build -t myapp:1.0 .
The . at the end is the build context — the directory Docker sends to the build process. Usually it is the current directory.
Dockerfile Quick Start with docker init
If you are starting a new project, use docker init to automatically generate a Dockerfile and docker-compose.yml:
docker init
Docker asks a few questions (language, version, port) and generates a starting-point Dockerfile for you. Review and customize it before using in production. This is the fastest way to get started with Go, Python, Node.js, Rust, and other languages.
Dockerfile Instructions Explained
FROM — Choose Your Base Image
Every Dockerfile starts with FROM. It sets the base image.
FROM python:3.13-slim
Choosing the right base image matters a lot:
| Base Image | Size | Use Case |
|---|---|---|
ubuntu:24.04 | ~80 MB | General purpose, familiar tools |
debian:bookworm-slim | ~75 MB | Small but with apt package manager |
python:3.13-slim | ~130 MB | Python apps (slim = fewer pre-installed packages) |
python:3.13-alpine | ~50 MB | Minimal Python (may have compatibility issues) |
gcr.io/distroless/python3 | ~45 MB | No shell, no package manager — production-hardened |
scratch | 0 MB | Empty image — for Go/Rust static binaries |
Rule of thumb: Use slim variants for most apps. Use alpine if size is critical (test compatibility). Use distroless or scratch for production security.
WORKDIR — Set the Working Directory
WORKDIR /app
Sets the current directory for all following instructions. Also creates the directory if it does not exist.
Always use WORKDIR instead of RUN mkdir /app && cd /app. It is cleaner and works correctly across platforms.
COPY vs ADD
COPY copies files from your build context into the image:
COPY requirements.txt .
COPY src/ ./src/
COPY . .
ADD does everything COPY does, plus:
- It can extract
.tar.gzarchives automatically - It can download from URLs (not recommended — use
RUN curlinstead for clarity)
Rule: Always use COPY. Only use ADD if you specifically need its tar extraction feature.
RUN — Execute Commands at Build Time
RUN executes a command during the build and creates a new layer with the result:
RUN apt update && apt install -y curl
RUN pip install flask gunicorn
Combine multiple RUN commands into one to reduce layers:
# Bad: creates 3 layers
RUN apt update
RUN apt install -y curl
RUN apt clean
# Good: creates 1 layer
RUN apt update && \
apt install -y curl && \
rm -rf /var/lib/apt/lists/*
Removing the apt cache (rm -rf /var/lib/apt/lists/*) in the same RUN command keeps the layer small.
ENV — Set Environment Variables
ENV PORT=8080
ENV APP_ENV=production
ENV LOG_LEVEL=info
These variables are available at runtime (when the container runs) and during the build. To pass build-time-only variables, use ARG instead:
ARG BUILD_VERSION=1.0
ARG is only available during the build, not at runtime.
EXPOSE — Document Ports
EXPOSE 8080
EXPOSE tells Docker that the container listens on this port. It does not publish the port to the host. You still need -p 8080:8080 when running the container.
Think of EXPOSE as documentation — it tells other developers which port the app uses.
CMD vs ENTRYPOINT
This is the most confusing part of Dockerfiles. Let me explain clearly.
CMD — the default command to run when the container starts:
CMD ["python", "app.py"]
If you run the container with a different command, CMD is overridden:
docker run myapp:1.0 python --version
# This runs "python --version" instead of "python app.py"
ENTRYPOINT — the fixed command that always runs:
ENTRYPOINT ["python", "app.py"]
You cannot override ENTRYPOINT by passing a command to docker run. You can only add arguments.
Combining ENTRYPOINT and CMD:
ENTRYPOINT ["python"]
CMD ["app.py"]
- Default: runs
python app.py - Override CMD:
docker run myapp:1.0 other_script.py→ runspython other_script.py - The entrypoint (
python) is fixed; the CMD (app.py) is the default argument
Use exec form (JSON array), not shell form:
# Shell form — avoid
CMD python app.py
# Exec form — preferred
CMD ["python", "app.py"]
Exec form runs the command directly. Shell form wraps it in /bin/sh -c, which means signals (like SIGTERM) are not passed to your app correctly.
USER — Run as Non-Root
By default, Docker containers run as root. This is a security risk. Use USER to switch to a non-root user:
# Create a system user with no home directory and no login shell
RUN groupadd --gid 1001 appgroup && \
useradd --uid 1001 --gid appgroup --no-create-home appuser
# Switch to that user
USER appuser
We cover this more in Docker Tutorial #9: Docker Security Best Practices.
HEALTHCHECK — Monitor Container Health
HEALTHCHECK tells Docker how to test if the container is healthy:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
Docker runs this command every 30 seconds. If it fails 3 times in a row, the container is marked as unhealthy. This is required for Docker Compose’s depends_on: condition: service_healthy.
Layer Caching: Order Matters
Docker caches each layer. If a layer has not changed, Docker reuses the cached version. This makes builds much faster.
The golden rule: Put instructions that change rarely at the top. Put instructions that change often at the bottom.
# Bad: changing your code invalidates ALL layers below it
FROM python:3.13-slim
WORKDIR /app
COPY . . # ← code changes often
RUN pip install -r requirements.txt # ← always reinstalled!
# Good: only reinstall deps when requirements.txt changes
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt . # ← only changes when deps change
RUN pip install -r requirements.txt # ← cached until deps change
COPY . . # ← copy code last
.dockerignore — Exclude Files from the Build Context
Create a .dockerignore file to exclude files you don’t want in the image:
# .dockerignore
.git
.gitignore
__pycache__
*.pyc
*.pyo
.env
.env.local
venv/
.venv/
*.log
node_modules/
.pytest_cache/
dist/
build/
Without .dockerignore, Docker sends your entire directory (including .git, node_modules, etc.) to the build process. This makes builds slow and images large.
Complete Example: Python API Dockerfile
Here is a complete, production-ready Dockerfile for a Python/FastAPI app:
# syntax=docker/dockerfile:1
# Use slim Python base image
FROM python:3.13-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PORT=8080
# Install system dependencies
RUN apt update && \
apt install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd --gid 1001 appgroup && \
useradd --uid 1001 --gid appgroup --no-create-home appuser
# Set working directory
WORKDIR /app
# Copy and install Python dependencies first (cache optimization)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
# Document the port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# Start the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
Build and run it:
docker build -t myapi:1.0 .
docker run -d -p 8080:8080 --name myapi myapi:1.0
Common Mistakes
Running as root
The most common Docker security mistake. Always add a USER instruction. A container running as root can potentially escape and affect the host system. We cover this in detail in Docker Tutorial #9: Docker Security Best Practices.
Not using .dockerignore
Sending node_modules/, .git/, or target/ to the build context wastes time and can accidentally include sensitive files like .env. Always create a .dockerignore.
Putting COPY . . before RUN npm install
This defeats layer caching. Every time you change any source file, Docker reinstalls all dependencies from scratch. Copy just the dependency file first, install, then copy the rest of the code.
Shell form in CMD/ENTRYPOINT
Using CMD python app.py instead of CMD ["python", "app.py"] means your app runs as a child of /bin/sh. Unix signals like SIGTERM (sent when docker stop runs) will not reach your app. Use exec form (JSON array) for CMD and ENTRYPOINT.
What’s Next?
In the next article, you will learn multi-stage builds — how to create production images that are 10x smaller by separating the build environment from the runtime.
But first, let’s learn Docker Compose so you can run multi-container apps.
Next: Docker Tutorial #5: Docker Compose — Run Multi-Container Apps