21.5. Dockerfiles
Table of Contents
Why Dockerfiles Matter
A Dockerfile is a text file with instructions that tells Docker exactly how to build an image for your application. Every time you run docker build, Docker reads your Dockerfile, follows the steps, and produces a new image.
You can think of a Dockerfile as a recipe:
- The ingredients are base images and packages.
- The steps are commands to copy code, install dependencies, and configure the environment.
- The final dish is the image that you can run as a container.
A Dockerfile is the only source of truth for how an image is built. If you install things manually into a running container without updating the Dockerfile, you will lose those changes when the container is rebuilt.
In this chapter you will learn the most important Dockerfile instructions, how build layers work, and how to write Dockerfiles that are efficient and suitable for backend applications.
The Basic Structure of a Dockerfile
At minimum, a Dockerfile usually has:
- A base image.
- Some commands to prepare the environment.
- Your application code.
- A command that runs when the container starts.
Here is a simple example for a Python backend app:
# 1. Base image
FROM python:3.11-slim
# 2. Set working directory inside the container
WORKDIR /app
# 3. Copy dependency list and install
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 4. Copy application code
COPY . .
# 5. Expose port (documentation only)
EXPOSE 8000
# 6. Command to run the app
CMD ["python", "main.py"]You will see these parts again and again in real backend Dockerfiles.
Common Dockerfile Instructions
Here are the most important instructions you will use.
FROM
FROM sets the base image. Every Dockerfile must start with FROM (except special cases with ARG before it).
Examples:
FROM python:3.11-slim
FROM node:20-alpine
FROM postgres:16Use an official image that matches the main language or service of your backend.
Always try to pin versions in FROM. For example, use python:3.11-slim, not just python:latest, to avoid unexpected behavior when the base image changes.
WORKDIR
WORKDIR sets the current working directory inside the container. Later instructions like COPY, RUN, and CMD will run relative to this directory.
WORKDIR /appIf the directory does not exist, Docker creates it.
COPY
COPY copies files from your host machine (where you run docker build) into the image.
COPY requirements.txt .
COPY src/ ./src
COPY . .Syntax:
COPY <source> <destination><source>is relative to the build context (usually the folder where the Dockerfile is).<destination>is inside the container, relative toWORKDIRif set.
RUN
RUN executes a command at build time and stores the result in the image. It is often used to install packages.
Examples:
RUN apt-get update && apt-get install -y build-essential
RUN pip install --no-cache-dir -r requirements.txt
RUN useradd -m appuser
Each RUN creates a new image layer. You will see later why this matters.
CMD
CMD defines the default command that runs when you start a container from the image.
CMD ["python", "main.py"]
You can override CMD when you run the container:
docker run myimage python another_script.py
You can write CMD in:
- exec form (recommended):
CMD ["python", "main.py"]- shell form:
CMD python main.pyExec form avoids an extra shell process and handles signals better.
ENTRYPOINT
ENTRYPOINT is similar to CMD but is harder to override. It defines the main process and CMD supplies default arguments.
Example:
ENTRYPOINT ["python", "main.py"]
CMD ["--host", "0.0.0.0", "--port", "8000"]Running:
docker run myimageexecutes:
python main.py --host 0.0.0.0 --port 8000If you run:
docker run myimage --port 9000it executes:
python main.py --port 9000
The new arguments replace the CMD arguments but keep the ENTRYPOINT.
ENV
ENV sets environment variables inside the image.
ENV PYTHONUNBUFFERED=1
ENV APP_ENV=productionYou can use them in later instructions:
ENV APP_DIR=/app
WORKDIR $APP_DIRRemember that secrets should not be baked into Dockerfiles. Use runtime environment configuration for secrets.
EXPOSE
EXPOSE documents which port the container listens on. It does not actually open a port on your host.
EXPOSE 8000You still need to map ports when running:
docker run -p 8000:8000 myimageARG
ARG defines build-time variables that can be passed to docker build with --build-arg.
ARG APP_VERSION=dev
RUN echo "Building version $APP_VERSION"Build with:
docker build --build-arg APP_VERSION=1.0.0 -t myimage:1.0.0 .
Unlike ENV, ARG values are not available at container runtime, only during the build.
USER
USER sets which user will run later instructions and the final process. By default, containers run as root, which is often not desirable in production.
Example:
RUN useradd -m appuser
USER appuserThis helps with security.
Build Context and .dockerignore
The build context is the folder you pass to docker build. Docker sends this entire folder (except excluded files) to the Docker daemon.
Example:
docker build -t myapp .
The . means the current directory is the build context.
Everything you want to COPY into the image must be inside the build context. However, if your build context is too large, builds become slow.
To control what is sent, use a .dockerignore file.
Example .dockerignore for a Python backend:
.git
__pycache__
*.pyc
*.pyo
*.pyd
.env
.env.*
venv
.venv
.idea
.vscode
dist
build
.coverage
htmlcov
*.log
Always use .dockerignore. Large build contexts cause slow builds and may accidentally send secrets (for example .env files) into your image.
Docker Image Layers and Caching
Every instruction in a Dockerfile produces a layer. Docker caches these layers so later builds can reuse them if nothing above that instruction changed.
This is powerful for backend development because installing dependencies can be expensive.
Consider:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]Build flow:
FROMdownloads base image. Cached for future builds.WORKDIRcreates directory, cached.COPY requirements.txt .copies only the requirements, cached.RUN pip install ...installs dependencies, cached.COPY . .copies the rest of the source, often changed.CMDset, cached.
If you modify only your application code, steps 1 to 4 are still valid in cache. Docker skips them and only re-runs COPY . . and later instructions. Your build is much faster.
If you change requirements.txt, step 3 changes, so step 4 must run again.
This is why you should usually:
- Copy the dependency file first.
- Install dependencies.
- Copy the rest of your source code.
Common Patterns for Backend Dockerfiles
Backend applications have some recurring needs:
- Install system-level build tools and libraries.
- Install language dependencies (Python packages, Node modules, Java jars, etc.).
- Configure environment variables.
- Run as a non-root user.
Example: Python FastAPI app with Uvicorn
A typical Dockerfile:
FROM python:3.11-slim
# 1. Environment settings (faster, more predictable)
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# 2. Install system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*
# 3. Create user and app directory
RUN useradd -m appuser
WORKDIR /app
# 4. Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 5. Copy application code
COPY . .
# 6. Switch to non-root user
USER appuser
# 7. Document exposed port
EXPOSE 8000
# 8. Run with uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]This pattern is appropriate for many small backend services.
Multi-Stage Builds
Multi-stage builds let you use multiple FROM instructions in one Dockerfile and copy artifacts from one stage to another.
This is useful when:
- You need heavy build tools (compilers) but do not want them in the final image.
- You want a small production image.
Example: building a Python app with compiled dependencies, then copying the result into a slim image.
# Stage 1: Build dependencies
FROM python:3.11 as builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY . .
# Stage 2: Final image
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# Copy source code
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]In more advanced setups, you may compile native extensions or frontend assets in the build stage and copy only the final files.
Best Practices for Backend Dockerfiles
Here are practical guidelines specific to backend apps.
1. Use Small Base Images
Prefer slim or alpine variants when possible:
python:3.11-slimnode:20-alpinegolang:1.22-alpine
Smaller images:
- Download faster.
- Start more quickly.
- Have fewer packages, which can reduce attack surface.
Be aware that alpine uses musl instead of glibc. Some Python packages and other binaries may not work well with alpine.
2. Combine RUN Instructions When Appropriate
Every RUN makes a new layer. You can combine commands for fewer layers and smaller images:
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential curl && \
rm -rf /var/lib/apt/lists/*However, do not combine unrelated commands just to save layers if it makes debugging harder.
3. Keep Build and Runtime Separate
Use multi-stage builds when you need:
- Build tools (like
gcc,make,node). - Test or lint steps.
Put them in a build stage, then copy only what you need into the final image. This avoids shipping compilers in production containers.
4. Do Not Bake Secrets into Images
Never put secrets directly in the Dockerfile:
- No passwords in
ENV. - No private keys with
COPY.
Use environment variables, secret managers, or runtime configuration when the container starts, not at build time.
5. Use .dockerignore Correctly
Exclude:
.gitand other VCS directories.- Local virtual environments.
- Build artifacts and logs.
.envfiles and other sensitive files.
This keeps builds fast and reduces the risk of leaking secrets.
6. Run as Non-root
If your base image supports it, create a user and switch to it:
RUN useradd -m appuser
USER appuserSome official images already have a non-root user; check their documentation.
7. Use Explicit Tags
Prefer:
FROM python:3.11-slimover:
FROM pythonThis helps you control when versions change.
Example: From Simple to Better Dockerfile
Suppose you start with a simple Dockerfile for your FastAPI app:
FROM python:3.11
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"]Problems:
- Using full
python:3.11image, larger than needed. - Installing dependencies after copying everything, which reduces cache effectiveness.
- Using
--reloadin production, which is for development. - Running as root.
A better version:
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# Install system dependencies only if needed
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*
# Install Python dependencies first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy app code
COPY . .
# Create and use non-root user
RUN useradd -m appuser
USER appuser
EXPOSE 8000
# No --reload in production
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]This second version is better suited for real backend deployment.
Summary
- A Dockerfile describes how to build a Docker image.
- Key instructions:
FROM,WORKDIR,COPY,RUN,ENV,CMD,ENTRYPOINT,EXPOSE,USER,ARG. - Docker images are built in layers, and Docker's build cache depends on instruction order.
- Use
.dockerignoreto keep build context small and secure. - For backend services, focus on:
- Using appropriate base images.
- Installing dependencies in a cache-friendly way.
- Keeping production images small and free of build tools.
- Avoiding secrets in the Dockerfile.
- Running as a non-root user where possible.
- Multi-stage builds help you separate build and runtime and create lean production images.
In later chapters, you will see Dockerfiles used for FastAPI, PostgreSQL, Redis, and multi-container setups.
Views: 7
KAHIBARO