Writing Your First Dockerfile

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"]
Terminal — build and run
# 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
PatternMatchesWhy 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
  1. 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!".
  2. 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.
  3. 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.
  4. 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.
  5. 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.