Phase 2 · CONTAINERS & CI/CD
Dockerfiles — writing & optimizing your first image
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
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-slimgives you a minimal Python already installed; everything else stacks on top.WORKDIR— sets (and creates) the working directory inside the image. AfterWORKDIR /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 currentWORKDIR.RUN— executes a command at build time and saves the result into the image.RUN pip install -r requirements.txtbakes 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-pat run time — tomorrow’s topic).CMD— the default command run when a container starts. UnlikeRUN, 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.
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
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
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
dockercommand when the daemon isn’t running — for exampledocker build -t myapp:1 .— prints:Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?Why: The
dockercommand 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 thedockerservice) is up, so the client has nothing to connect to. The same message appears when your user isn’t in thedockergroup and can’t reach the socket.Fix: Start Docker Desktop (or
sudo service docker starton a native install), then re-run. If it’s a permission issue, add yourself withsudo usermod -aG docker $USERand open a new shell so the group takes effect. Confirm withdocker 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
dockercall 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 thedockergroup before touching the build.
Common error: A
COPYinstruction naming a file that isn’t in the build context — building a Dockerfile withCOPY 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 foundWhy:
COPYcan only see files inside the build context — the directory you passed as the final.todocker build. If the file is misspelled, sits outside that directory, or is matched by a.dockerignorepattern, 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.dockerignorerule 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
COPYis 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:1while 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:containerthat 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 withdocker rm -f web. Usedocker psto 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 psandss -tlnpfor whoever is holding it before rebinding.
Dockerfile Interview Questions
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
Optional extras if you have ~30 more minutes today:
- 5 min — Run
docker history myapp:2again and read every layer against your Dockerfile, thendocker image inspect myapp:2to 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