KAHIBARO
Discord Login Register

21.5. Dockerfiles

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:

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:

  1. A base image.
  2. Some commands to prepare the environment.
  3. Your application code.
  4. A command that runs when the container starts.

Here is a simple example for a Python backend app:

dockerfile
# 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:

dockerfile
FROM python:3.11-slim
FROM node:20-alpine
FROM postgres:16

Use 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.

dockerfile
WORKDIR /app

If the directory does not exist, Docker creates it.

COPY

COPY copies files from your host machine (where you run docker build) into the image.

dockerfile
COPY requirements.txt .
COPY src/ ./src
COPY . .

Syntax:

dockerfile
COPY <source> <destination>

RUN

RUN executes a command at build time and stores the result in the image. It is often used to install packages.

Examples:

dockerfile
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.

dockerfile
CMD ["python", "main.py"]

You can override CMD when you run the container:

bash
docker run myimage python another_script.py

You can write CMD in:

dockerfile
  CMD ["python", "main.py"]
dockerfile
  CMD python main.py

Exec 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:

dockerfile
ENTRYPOINT ["python", "main.py"]
CMD ["--host", "0.0.0.0", "--port", "8000"]

Running:

bash
docker run myimage

executes:

bash
python main.py --host 0.0.0.0 --port 8000

If you run:

bash
docker run myimage --port 9000

it executes:

bash
python main.py --port 9000

The new arguments replace the CMD arguments but keep the ENTRYPOINT.

ENV

ENV sets environment variables inside the image.

dockerfile
ENV PYTHONUNBUFFERED=1
ENV APP_ENV=production

You can use them in later instructions:

dockerfile
ENV APP_DIR=/app
WORKDIR $APP_DIR

Remember 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.

dockerfile
EXPOSE 8000

You still need to map ports when running:

bash
docker run -p 8000:8000 myimage

ARG

ARG defines build-time variables that can be passed to docker build with --build-arg.

dockerfile
ARG APP_VERSION=dev
RUN echo "Building version $APP_VERSION"

Build with:

bash
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:

dockerfile
RUN useradd -m appuser
USER appuser

This 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:

bash
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:

text
.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:

dockerfile
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:

  1. FROM downloads base image. Cached for future builds.
  2. WORKDIR creates directory, cached.
  3. COPY requirements.txt . copies only the requirements, cached.
  4. RUN pip install ... installs dependencies, cached.
  5. COPY . . copies the rest of the source, often changed.
  6. CMD set, 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:

  1. Copy the dependency file first.
  2. Install dependencies.
  3. Copy the rest of your source code.

Common Patterns for Backend Dockerfiles

Backend applications have some recurring needs:

Example: Python FastAPI app with Uvicorn

A typical Dockerfile:

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:

Example: building a Python app with compiled dependencies, then copying the result into a slim image.

dockerfile
# 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:

Smaller images:

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:

dockerfile
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:

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:

Use environment variables, secret managers, or runtime configuration when the container starts, not at build time.

5. Use .dockerignore Correctly

Exclude:

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:

dockerfile
RUN useradd -m appuser
USER appuser

Some official images already have a non-root user; check their documentation.

7. Use Explicit Tags

Prefer:

dockerfile
FROM python:3.11-slim

over:

dockerfile
FROM python

This helps you control when versions change.


Example: From Simple to Better Dockerfile

Suppose you start with a simple Dockerfile for your FastAPI app:

dockerfile
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:

A better version:

dockerfile
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

In later chapters, you will see Dockerfiles used for FastAPI, PostgreSQL, Redis, and multi-container setups.

Views: 7

Comments

Please login to add a comment.

Don't have an account? Register now!