Skip to content

Phase 2 · CONTAINERS & CI/CD

Dockerfiles — writing & optimizing your first image

Day 24 of 90 ~60 min 0/25 in phase Builds on Day 23

By the end of today

  • Write a Dockerfile from FROM to CMD and build it into an image
  • Order instructions so Docker's layer cache actually speeds up your rebuilds
  • Shrink build context and images with .dockerignore and a slim base

A Dockerfile: the recipe that builds your image

Section 1 of 5 · ~3 min

Yesterday you ran images other people built. Today you write the file that builds one. A Dockerfile is a plain-text recipe: a list of instructions, top to bottom, that docker build follows to produce an image. Learn six instructions and you can containerise almost any app.

  • FROM — the base image you start from, always the first line. FROM python:3.12-slim gives you a minimal Python already installed; everything else stacks on top.
  • WORKDIR — sets (and creates) the working directory inside the image. After WORKDIR /app, every later command runs from /app, so you stop writing long absolute paths.
  • COPY — copies files from your machine (the build context) into the image. COPY . . copies your project into the current WORKDIR.
  • RUN — executes a command at build time and saves the result into the image. RUN pip install -r requirements.txt bakes your dependencies in.
  • EXPOSE — documents which port the app listens on. It’s a label for humans and tooling; it does not publish the port (that’s -p at run time — tomorrow’s topic).
  • CMD — the default command run when a container starts. Unlike RUN, it fires at run time, not build time. CMD ["python", "app.py"] launches the app.

Then docker build -t myapp:1 . reads the Dockerfile in the current directory (the final . is the build context), tags the result myapp:1, and hands you a runnable image.

Layers and the build cache

Every instruction creates a layer — a stacked, read-only diff on top of the one before. This is the single most important thing to understand about writing Dockerfiles, because Docker caches each layer. On a rebuild it walks the instructions and reuses the cached layer for each one whose inputs haven’t changed — until it hits the first change. From that instruction down, every layer is rebuilt.

That turns instruction order into a performance decision. Put what rarely changes (installing dependencies) high, and what changes on every commit (your source code) low, so a one-line code edit reuses the expensive dependency layer instead of reinstalling everything.

A Dockerfile builds a stack of cached layers: FROM the base, WORKDIR, then COPY requirements and RUN install stay cached, while COPY of source code and below are rebuilt whenever the code changes. CMD ["python", "app.py"] COPY . . (source code) RUN pip install COPY requirements.txt . FROM python:3.12-slim cached — reused rebuilt on code change
Stable layers (base, deps) sit low and stay cached; volatile layers (your code) sit high and rebuild — that ordering is the whole optimisation.

One more file earns its keep: .dockerignore. It lists paths to exclude from the build context — node_modules, .git, local .env files — so they’re never shipped to the daemon or picked up by COPY . .. It reads exactly like .gitignore, one glob per line.

Real world: A Dockerfile is a baking recipe and the cache is your mise en place. Sifting flour and measuring the dry goods rarely changes, so a smart baker does it once and keeps it ready; only the frosting changes per order. Prep the stable base ahead, redo only the last step per cake. Put the flour-sifting last and you’d re-sift for every single order — which is exactly what a badly ordered Dockerfile does on every rebuild.

A named example makes it concrete. The maintainers of the official Node.js and Python images on Docker Hub publish an alpine variant built on Alpine Linux, whose entire base is around 5 MB. Swapping FROM node:20 for FROM node:20-alpine can drop a finished image from roughly a gigabyte to a couple hundred megabytes (and under ~50 MB to pull) — the same app, far less to store, transfer and attack. Choosing the base image is the first optimisation you make.

Six instructions, layers you order deliberately, and a .dockerignore to keep the junk out — that’s a real, optimised image, and it’s what you build by hand below. (Squeezing it smaller still, with multi-stage builds, is Day 29.)

Hands-On Lab

Section 2 of 5 · ~4 min

Budget about 30 minutes. Open your WSL2 Ubuntu 24.04 terminal with Docker 27+ installed (if you have not set Docker up yet, revisit Day 23 first). You will build a tiny Python web app into an image, run it, then prove the layer cache works by editing one line and rebuilding — type every command yourself and read every line of output.

# 1. Make a project directory and move into it.
mkdir -p ~/dockerfile-lab && cd ~/dockerfile-lab
pwd
# Output:
# /home/pushkar/dockerfile-lab
# 2. Write a tiny Flask app that answers on port 5000.
cat > app.py <<'EOF'
from flask import Flask
app = Flask(__name__)

@app.route("/")
def home():
    return "Hello from Docker!\n"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
EOF
cat app.py
# Output:
# from flask import Flask
# app = Flask(__name__)
#
# @app.route("/")
# def home():
#     return "Hello from Docker!\n"
#
# if __name__ == "__main__":
#     app.run(host="0.0.0.0", port=5000)
# 3. Declare the one dependency the app needs.
echo "flask==3.0.3" > requirements.txt
cat requirements.txt
# Output:
# flask==3.0.3
# 4. Write the Dockerfile — six instructions, deps copied BEFORE the code so they cache.
cat > Dockerfile <<'EOF'
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
EOF
cat Dockerfile
# Output:
# FROM python:3.12-slim
# WORKDIR /app
# COPY requirements.txt .
# RUN pip install --no-cache-dir -r requirements.txt
# COPY . .
# EXPOSE 5000
# CMD ["python", "app.py"]
# 5. Keep the build context lean: never ship git, caches or local env into the image.
cat > .dockerignore <<'EOF'
.git
__pycache__
*.pyc
.env
EOF
cat .dockerignore
# Output:
# .git
# __pycache__
# *.pyc
# .env
# 6. Build the image and tag it myapp:1. The final "." is the build context.
docker build -t myapp:1 .
# Output (BuildKit; image IDs, digests, sizes and timings are yours-will-differ):
# [+] Building 14.7s (11/11) FINISHED                              docker:default
#  => [internal] load build definition from Dockerfile                       0.0s
#  => [internal] load metadata for docker.io/library/python:3.12-slim        1.1s
#  => [internal] load .dockerignore                                          0.0s
#  => [1/5] FROM docker.io/library/python:3.12-slim@sha256:<digest>          3.2s
#  => [internal] load build context                                          0.0s
#  => => transferring context: 1.05kB                                        0.0s
#  => [2/5] WORKDIR /app                                                      0.1s
#  => [3/5] COPY requirements.txt .                                          0.0s
#  => [4/5] RUN pip install --no-cache-dir -r requirements.txt               6.8s
#  => [5/5] COPY . .                                                         0.0s
#  => exporting to image                                                     0.3s
#  => => writing image sha256:<your-image-id>                                0.0s
#  => => naming to docker.io/library/myapp:1                                 0.0s
# 7. Confirm the image exists and is tagged. (SIZE and IMAGE ID are yours-will-differ.)
docker images myapp
# Output:
# REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
# myapp        1         3f9a1c8be2d1   10 seconds ago   181MB
# 8. Run it in the background, publishing container port 5000 as host port 8000.
docker run -d -p 8000:5000 --name web myapp:1
# Output (the container ID is yours-will-differ):
# 7b2e4d9c1a83f60c5e8d2a4b6f1093e7c2d5a8b4e0f3c9d17a2b6e5c8d4f0a19
# 9. Prove the app answers through the published port.
curl http://localhost:8000/
# Output:
# Hello from Docker!
# 10. Change ONE line of app code, then rebuild — watch every dependency layer come back CACHED.
sed -i 's/Hello from Docker!/Hello from my first image!/' app.py
docker build -t myapp:2 .
# Output (steps 1-4 are reused; only the code COPY re-runs — seconds, not minutes):
# [+] Building 0.9s (11/11) FINISHED                              docker:default
#  => CACHED [1/5] FROM docker.io/library/python:3.12-slim@sha256:<digest>   0.0s
#  => CACHED [2/5] WORKDIR /app                                              0.0s
#  => CACHED [3/5] COPY requirements.txt .                                   0.0s
#  => CACHED [4/5] RUN pip install --no-cache-dir -r requirements.txt        0.0s
#  => [5/5] COPY . .                                                         0.0s
#  => exporting to image                                                     0.1s
# 11. Read the layers back with docker history (SIZE column is yours-will-differ).
docker history myapp:2
# Output (trimmed to this image's own layers — the base python:3.12-slim layers are omitted.
# Every row but the top shows <missing> because BuildKit does not record standalone
# image IDs for intermediate layers — it is NOT a "base layer" marker):
# IMAGE          CREATED          CREATED BY                                      SIZE
# a1b2c3d4e5f6   5 seconds ago    CMD ["python" "app.py"]                         0B
# <missing>      5 seconds ago    EXPOSE map[5000/tcp:{}]                         0B
# <missing>      5 seconds ago    COPY . . # buildkit                             1.2kB
# <missing>      2 minutes ago    RUN pip install --no-cache-dir -r requireme…    9.4MB
# <missing>      2 minutes ago    COPY requirements.txt . # buildkit              13B
# <missing>      2 minutes ago    WORKDIR /app                                    0B
# 12. Clean up the running container so the port is free for tomorrow.
docker rm -f web
# Output:
# web

Read the last steps back: you wrote six instructions, built them into a tagged image, ran it and got a real HTTP response — then changed one line and rebuilt in under a second because the pip install layer stayed cached. That cache is why instruction order is the first optimisation, not an afterthought.

Common Errors & Fixes

Section 3 of 5 · ~3 min

These three trip up almost everyone building their first image on Ubuntu 24.04. Read the error text slowly — learning to parse it is the actual skill.

Common error: Running any docker command when the daemon isn’t running — for example docker build -t myapp:1 . — prints:

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?

Why: The docker command is only a client; it talks to a background daemon (dockerd) over that socket. On WSL2 the daemon isn’t started automatically unless Docker Desktop (or the docker service) is up, so the client has nothing to connect to. The same message appears when your user isn’t in the docker group and can’t reach the socket.

Fix: Start Docker Desktop (or sudo service docker start on a native install), then re-run. If it’s a permission issue, add yourself with sudo usermod -aG docker $USER and open a new shell so the group takes effect. Confirm with docker info, which prints daemon details once the connection works.

How you’d spot it in prod: A CI job or deploy script failing at the very first docker call with “Cannot connect to the Docker daemon” means the runner has no daemon or the user lacks socket access — it’s environment, not your Dockerfile. Check that the Docker service is up and the job user is in the docker group before touching the build.

Common error: A COPY instruction naming a file that isn’t in the build context — building a Dockerfile with COPY requirements.txt . when the file is missing or excluded — prints:

failed to solve: failed to compute cache key: failed to calculate checksum of ref ...: "/requirements.txt": not found

Why: COPY can only see files inside the build context — the directory you passed as the final . to docker build. If the file is misspelled, sits outside that directory, or is matched by a .dockerignore pattern, Docker never received it and can’t copy it. It resolves paths relative to the context, not to where the Dockerfile happens to live.

Fix: Confirm the file is actually in the context with ls, check the spelling in the Dockerfile, and make sure no .dockerignore rule excludes it. Run the build from the directory that contains both the Dockerfile and the files it copies, or point the context there explicitly.

How you’d spot it in prod: A build that passes locally but fails in CI with “not found” on a COPY is usually a context mismatch — the CI checkout omits a file (a git-ignored artifact, a generated config) that exists on your laptop. Compare what’s committed against what the Dockerfile expects to copy.

Common error: Starting a second container on a host port that’s already taken — docker run -d -p 8000:5000 --name web2 myapp:1 while the first is still running — prints:

docker: Error response from daemon: driver failed programming external connectivity on endpoint web2: Bind for 0.0.0.0:8000 failed: port is already allocated.

Why: Only one process can bind a given host port at a time. The first container already published host port 8000, so the daemon can’t map a second container onto it. The container port (5000) can repeat freely — it’s the host side of -p host:container that must be unique.

Fix: Publish the new container on a different host port (-p 8001:5000), or stop and remove the one holding 8000 first with docker rm -f web. Use docker ps to see which container owns a published port before you launch another.

How you’d spot it in prod: A deploy that fails with “port is already allocated” usually means the previous version’s container wasn’t stopped before the new one started, or another service on the host already owns that port. Check docker ps and ss -tlnp for whoever is holding it before rebinding.

Dockerfile Interview Questions

Section 4 of 5 · ~1 min

The instruction roles and the layer-cache reasoning below are among the most common first-round Docker screening questions — a calm answer that explains why order matters beats reciting flags. Cover each answer, say your own version out loud first, then compare — recalling before revealing is what makes it stick for interview day. The four questions and answers render right after this note.

Go Deeper

Section 5 of 5 · ~1 min

Optional extras if you have ~30 more minutes today:

  • 5 min — Run docker history myapp:2 again and read every layer against your Dockerfile, then docker image inspect myapp:2 to see the config, exposed ports and layer digests the build recorded.
  • 10 min — Skim Docker’s official Dockerfile best practices and note which of today’s choices — slim base, .dockerignore, deps-before-code ordering — it recommends and why.
  • 15 min — Read the images-and-layers section of the Docker for DevOps guide for how Dockerfiles, layers and the build cache fit the wider container workflow you build across this phase.
What is the difference between RUN, CMD and ENTRYPOINT in a Dockerfile? Both

RUN executes a command at build time and bakes the result into a new layer — that's how you install packages or compile code. CMD and ENTRYPOINT both run at container start, not build. The difference between those two: ENTRYPOINT sets the executable that always runs, and CMD supplies the default arguments, which anyone can override on docker run. A common pattern is ENTRYPOINT for the binary and CMD for its default flags. If you just need a simple default command, CMD alone is fine. The mistake I watch for is putting a startup command in RUN — it runs once during the build and is gone by the time the container actually starts.

Why does the order of instructions in a Dockerfile matter? Both

Docker builds an image as a stack of layers, one per instruction, and caches each one. On a rebuild it reuses cached layers until it hits the first instruction whose inputs changed — from there down, everything is rebuilt. So order is a performance decision: put what rarely changes near the top and what changes constantly near the bottom. The classic example is copying your dependency manifest and installing packages before copying the rest of your source. That way editing one line of application code doesn't invalidate the expensive dependency-install layer, and your rebuilds drop from minutes to seconds. Get the order backwards and every edit reinstalls everything.

What does a .dockerignore file do and why does it matter? Both

A .dockerignore file lists paths Docker should exclude from the build context — the set of files sent to the daemon when a build starts. Without it, docker build ships your entire directory, including node_modules, the .git folder, local env files and build artifacts, which is slow and can leak secrets into the image. With it, those never get sent or copied. It works like .gitignore: one glob pattern per line. The first entries I add on almost any project are node_modules and .git. It also makes COPY . . safe, because the junk you'd never want in an image is filtered out before COPY even runs.

How would you make a Docker image smaller? Product

A few habits do most of the work. Start from a slim base — python:3.12-slim or node:20-alpine instead of the full image — which alone can cut hundreds of megabytes. Combine related RUN commands and clean up in the same layer, because deleting files in a later layer doesn't shrink the earlier one that added them. Use .dockerignore so build junk never enters. And the biggest lever is a multi-stage build: you compile in a heavy builder stage and copy only the finished artifact into a tiny final image, leaving the compilers and dev dependencies behind. That's Day 29, but it's the technique that takes an image from gigabytes to tens of megabytes.

Mark Day 24 complete

Tomorrow you learn Docker networking and port mapping — how a container actually accepts traffic from the outside world.

Stuck on today’s lab? Ask in Mission 90 Q&A