Chapter 6 — Writing Your First Dockerfile
Until now you've used images that someone else built. A Dockerfile lets you
define exactly what goes into your own image — which base OS, which packages to
install, which files to include, and how to start the application. Once you can
write a Dockerfile, you can package any application into a portable, repeatable
image that runs the same way on your laptop, your Raspberry Pi, and any server.
This chapter covers every common Dockerfile instruction with real examples,
the build caching system that makes rebuilds fast, and best practices that keep
your images small and secure.
1. What a Dockerfile Is
A Dockerfile is a plain text file named exactly Dockerfile
(no extension) that lives in your project folder. Each line is an instruction
that adds a layer to the image. When you run docker build, Docker
reads the file top to bottom and executes each instruction in order.
# The simplest possible Dockerfile
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl
CMD ["curl", "--version"]
# Build an image from the Dockerfile in the current directory
# -t gives it a name:tag. The trailing . is the build context (current folder)
$ docker build -t my-curl:1.0 .
[+] Building 8.3s (6/6) FINISHED
=> [internal] load build context 0.0s
=> [1/3] FROM ubuntu:24.04 2.1s
=> [2/3] RUN apt-get update && apt-get install -y curl 5.8s
=> [3/3] CMD ["curl", "--version"] 0.0s
=> exporting to image 0.4s
$ docker run --rm my-curl:1.0
curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13...
The build context is the folder you pass to docker build
(the . at the end). Docker sends all files in that folder to the
Docker daemon as the "context" — files you reference in COPY
instructions must be inside it. Large build contexts slow builds down, which is
why .dockerignore matters (Section 6).
2. Dockerfile Instructions
FROM
FROM image:tag
Sets the base image. Every Dockerfile must start with FROM. Choose the smallest base that includes what you need.
RUN
RUN command
Executes a shell command during the build and saves the result as a new layer. Used for installing packages, creating directories, etc.
COPY
COPY src dest
Copies files from the build context (your project folder) into the image. Prefer COPY over ADD for simple file copying.
ADD
ADD src dest
Like COPY but also extracts .tar files and can fetch URLs. Use COPY unless you specifically need these extra features.
WORKDIR
WORKDIR /path
Sets the working directory for all subsequent RUN, COPY, CMD, and ENTRYPOINT instructions. Creates the directory if it doesn't exist.
EXPOSE
EXPOSE port
Documents which port the container listens on. Does NOT actually publish the port — you still need -p when running. It's informational.
ENV
ENV KEY=value
Sets an environment variable that's available during the build and inside running containers. Can be overridden with -e at runtime.
ARG
ARG NAME=default
A build-time variable passed with --build-arg. Unlike ENV, ARG values are not available in running containers. Good for version numbers.
CMD
CMD ["exec", "arg"]
The default command to run when the container starts. Can be overridden by passing a command to docker run. Only the last CMD takes effect.
ENTRYPOINT
ENTRYPOINT ["exec"]
Like CMD but harder to override — docker run arguments are appended rather than replacing it. Use for containers that behave like a command.
VOLUME
VOLUME /path
Declares a mount point. Docker will create an anonymous volume here if no -v is specified. Signals to users that this path contains persistent data.
USER
USER username
Switch to a non-root user for subsequent instructions and for running the container. Best practice: don't run as root unless necessary.
3. A Real Dockerfile — Python Web App
Let's build a Dockerfile for a simple FastAPI application. This is a
realistic starting point for the Python dev scenario in Chapter 8.
# Project structure:
# myapp/
# Dockerfile
# requirements.txt
# main.py
# .dockerignore
############################################
# Dockerfile
############################################
# 1. Start from the official slim Python image
FROM python:3.12-slim
# 2. Set the working directory inside the image
WORKDIR /app
# 3. Copy requirements first — enables layer caching (explained in Section 4)
COPY requirements.txt .
# 4. Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# 5. Copy the rest of the application code
COPY . .
# 6. Document the port (informational)
EXPOSE 8000
# 7. Switch to a non-root user for security
RUN useradd -m appuser && chown -R appuser /app
USER appuser
# 8. Start the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# requirements.txt
fastapi==0.111.0
uvicorn==0.30.1
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {"message": "Hello from Docker!"}
Terminal — build and run the app
$ docker build -t myapp:1.0 .
[+] Building 12.4s (9/9) FINISHED
=> [1/5] FROM python:3.12-slim 3.2s
=> [2/5] WORKDIR /app 0.0s
=> [3/5] COPY requirements.txt . 0.0s
=> [4/5] RUN pip install --no-cache-dir -r requirements.txt 8.1s
=> [5/5] COPY . . 0.1s
$ docker run -d -p 8000:8000 --name myapp myapp:1.0
$ curl http://localhost:8000
{"message":"Hello from Docker!"}
4. Layer Caching — Making Builds Fast
Every instruction in a Dockerfile creates a layer. Docker caches each layer
and reuses it if nothing above it has changed. This is why the order of your
instructions matters enormously for build speed.
1
FROM
python:3.12-slim base image
CACHED ✓
↓
2
WORKDIR
Set /app as working dir
CACHED ✓
↓
3
COPY
requirements.txt . ← only changes when deps change
CACHED ✓
↓
4
RUN
pip install requirements ← expensive — takes 8s
CACHED ✓
↓
5
COPY
. . ← you edit main.py → cache MISS here
REBUILT ↻
↓
6
CMD
Start uvicorn
REBUILT ↻
When you edit main.py, only steps 5 and 6 are rebuilt — the
slow pip install step is reused from cache. If you had copied all
files first and then run pip install, every code change would invalidate
the pip cache and rebuild dependencies from scratch.
The golden rule of cache ordering: Things that change rarely
go near the top. Things that change often go near the bottom. Dependencies
(requirements.txt, package.json) before source code.
Config before application logic.
Terminal — second build after editing main.py
$ docker build -t myapp:1.1 .
[+] Building 0.8s (9/9) FINISHED
=> [1/5] FROM python:3.12-slim CACHED
=> [2/5] WORKDIR /app CACHED
=> [3/5] COPY requirements.txt . CACHED
=> [4/5] RUN pip install --no-cache-dir -r requirements.txt CACHED
=> [5/5] COPY . . 0.1s
=> exporting to image 0.2s
# 12 seconds → 0.8 seconds just by ordering instructions correctly
5. CMD vs ENTRYPOINT
Both define what runs when a container starts, but they behave differently
when you pass extra arguments to docker run:
CMD
Provides default arguments. Completely replaced if you pass
a command to docker run. Use when you want a sensible
default that users can override.
Dockerfile: CMD ["python", "app.py"]
docker run myimage
→ runs: python app.py ✓
docker run myimage bash
→ runs: bash (CMD ignored)
ENTRYPOINT
Always runs. Arguments to docker run are appended
after the ENTRYPOINT, not replacing it. Use when the container
should always behave like a specific command.
Dockerfile: ENTRYPOINT ["python"]
docker run myimage app.py
→ runs: python app.py ✓
docker run myimage --version
→ runs: python --version ✓
The most flexible pattern combines both: ENTRYPOINT sets the
executable and CMD provides the default argument — which can be
overridden at runtime:
ENTRYPOINT ["uvicorn"]
CMD ["main:app", "--host", "0.0.0.0", "--port", "8000"]
# Normal run: uvicorn main:app --host 0.0.0.0 --port 8000
docker run myapp
# Override port at runtime without touching the image
docker run myapp main:app --host 0.0.0.0 --port 9000
Always use the exec form — ["executable", "arg1", "arg2"]
— for both CMD and ENTRYPOINT. The shell form (CMD command arg1) runs
via /bin/sh -c, which means signals like SIGTERM don't reach your
process. This breaks graceful shutdown, causing Docker to forcibly kill the
container after a 10-second wait.
6. The .dockerignore File
Just like .gitignore, a .dockerignore file in
your project root tells Docker which files to exclude from the build context.
This matters for two reasons: speed (don't send large folders Docker doesn't need)
and security (don't accidentally bake secrets into your image).
# .dockerignore
.git
.gitignore
__pycache__
*.pyc
*.pyo
.env # CRITICAL — never bake secrets into an image
.env.*
*.log
node_modules # if you have any JS tooling
.venv # Python virtual environment
venv
tests/
docs/
*.md
Dockerfile
docker-compose.yml
| Pattern | Matches | Why exclude |
| .env |
Environment files with secrets |
API keys, passwords end up in the image layer history |
| .git |
Git repository data |
Can be hundreds of MB, never needed in the image |
| __pycache__ / *.pyc |
Python bytecode |
Platform-specific, rebuilt inside the container anyway |
| .venv / venv |
Virtual environment |
Host packages aren't compatible inside the container — pip installs fresh |
| tests/ docs/ |
Test and doc folders |
Not needed at runtime — keeps the image smaller |
| node_modules |
npm packages |
Thousands of files, often gigabytes — reinstall inside is cleaner |
Never put secrets in a Dockerfile or image. Even if you
delete a secret in a later layer (RUN rm .env), it still exists
in the earlier layer and can be extracted with docker image history
or docker save. Pass secrets at runtime with -e flags
or Docker secrets, never bake them in at build time.
7. Building, Tagging and Running
# Build with a name and tag
docker build -t myapp:1.0 .
# Build from a Dockerfile in a different location
docker build -t myapp:1.0 -f path/to/Dockerfile .
# Build with a build-time argument
docker build --build-arg PYTHON_VERSION=3.12 -t myapp:1.0 .
# Tag an existing image with a second name
docker tag myapp:1.0 myapp:latest
# List images — confirm yours is there
docker images myapp
# Run your image
docker run -d -p 8000:8000 --name myapp myapp:1.0
# See the build output in verbose mode
docker build --progress=plain -t myapp:1.0 .
# Force a fresh build ignoring all cache
docker build --no-cache -t myapp:1.0 .
8. Keeping Images Small
# ✗ BAD — three RUN commands = three layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# ✓ GOOD — one layer, cache cleaned in the same step
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# ✓ Use slim or alpine base images when possible
FROM python:3.12-slim # ~45 MB vs ~1 GB for python:3.12
FROM python:3.12-alpine # ~22 MB — even smaller, fewer preinstalled tools
# ✓ Use --no-install-recommends with apt to skip optional packages
RUN apt-get install -y --no-install-recommends curl
# ✓ Use --no-cache-dir with pip to skip the pip cache
RUN pip install --no-cache-dir -r requirements.txt
# ✓ Use --no-cache with apk (Alpine's package manager)
RUN apk add --no-cache curl
Check your image size after building. Run docker images myapp
and look at the SIZE column. If it's unexpectedly large, use
docker image history myapp:1.0 to see which layer is responsible.
The culprit is almost always a package install step that didn't clean up the
package manager cache.
Exercises
- Build your first image. Create a folder called
hello-docker with a Dockerfile that starts from alpine, runs echo "Image built successfully" during the build, and has a CMD of ["echo", "Container started!"]. Build it with docker build -t hello-docker:1.0 . and run it with docker run --rm hello-docker:1.0. Then override CMD by running docker run --rm hello-docker:1.0 echo "Override works!".
- Build the FastAPI app. Create the three files from Section 3 (
Dockerfile, requirements.txt, main.py) in a new folder. Build the image and run the container. Visit http://localhost:8000 in your browser (or http://localhost:8000/docs for the automatic FastAPI docs). Then edit main.py to change the return message, rebuild, and observe how only the last two steps are rebuilt thanks to caching.
- Prove the caching order matters. Create a second Dockerfile that copies ALL files before running pip install (reverse the order of COPY and RUN from the example). Build it, then make a small change to
main.py and build again. Time how long it takes compared to the correctly ordered version — pip install runs again from scratch every time.
- Create a .dockerignore file. Add a
.env file with SECRET_KEY=topsecret to your FastAPI project folder. Build the image without a .dockerignore and run docker run --rm myapp cat /app/.env — you'll see the secret is inside the image. Now add a .dockerignore excluding .env, rebuild, and run the same command — it should say "No such file or directory". This is why .dockerignore matters for security.
- Reduce image size. Build the FastAPI app using
python:3.12 as the base and note the size. Then change to python:3.12-slim and rebuild — compare the sizes with docker images. Finally try python:3.12-alpine and resolve any dependency issues that arise (Alpine uses musl libc, which occasionally causes pip install failures for packages with C extensions). Note which base image gives the best balance of size and compatibility.
Next: Chapter 7 — Scenario: Apache Web Server
Fundamentals complete — time for real scenarios. Chapter 7 builds a practical
Apache web server container you can use as a fallback for osztromok.com: pulling
the official httpd image, mounting your site files, configuring virtual hosts,
and making it publicly accessible using Cloudflare Tunnel — no router port
forwarding needed.