A Dockerfile is your image recipe — a plain text file that turns into a reproducible, layered container image. This lesson teaches you to write one from scratch, understand every instruction, and avoid the most common mistakes.

1. What Is a Dockerfile?

A Dockerfile is a text file containing an ordered sequence of instructions. Each instruction creates one layer in the final image. Think of it as a repeatable, version-controllable recipe that tells Docker exactly how to build an image.

  • Plain text — no special editor needed, lives in your repo alongside your code.
  • Ordered — instructions execute top to bottom; order matters for caching.
  • One instruction = one layer — understanding this is key to optimizing builds.
  • Deterministic — given the same Dockerfile and context, you get the same image (modulo upstream changes).

2. The Anatomy — A Complete Dockerfile

Here's a production-ready Dockerfile for a Node.js app, line by line:

1  FROM node:20-slim
2  WORKDIR /app
3  COPY package*.json ./
4  RUN npm ci --production
5  COPY . .
6  EXPOSE 3000
7  CMD ["node", "server.js"]
LineInstructionWhat It Does
1FROM node:20-slimSets the base image. Every Dockerfile must start with FROM. node:20-slim is a minimal Debian image with Node.js 20 pre-installed.
2WORKDIR /appSets the working directory inside the container. All subsequent commands run relative to /app. Creates the directory if it doesn't exist.
3COPY package*.json ./Copies package.json and package-lock.json from host into the image. Done separately so this layer is cached unless dependencies change.
4RUN npm ci --productionExecutes a command at build time. Installs dependencies. Creates a new layer with node_modules/.
5COPY . .Copies the rest of the application source code. Done AFTER npm ci to preserve the dependency cache.
6EXPOSE 3000Documents which port the app listens on. Does NOT actually publish the port — that's -p at runtime.
7CMD ["node", "server.js"]The default command to run when the container starts. Can be overridden with docker run <image> <other-cmd>.

How a Dockerfile Becomes an Image

Dockerfile FROM node:20-slim WORKDIR /app COPY package*.json ./ RUN npm ci --production COPY . . EXPOSE 3000 CMD ["node","server.js"] docker build Build Process Step 1/7: Pull base Step 2/7: Set workdir Step 3/7: Copy pkg files Step 4/7: Run npm ci Step 5/7: Copy source Step 6/7: Set metadata Step 7/7: Set CMD produces Image (Layers) CMD metadata COPY . . (app source) RUN npm ci (node_modules) COPY package*.json WORKDIR /app Base: node:20-slim (Debian + Node.js)

3. Key Instructions Reference

InstructionPurposeWhen to Use
FROMSet base imageAlways first line. Multi-stage builds can have multiple FROMs.
RUNExecute a command at build timeInstall packages, compile code, create directories.
COPYCopy files/dirs from build context into imageYour app code, config files. Preferred over ADD.
ADDLike COPY but can extract .tar and fetch URLsOnly when you need auto-extraction. Otherwise use COPY.
CMDDefault command/argumentsWhat runs when no command is specified at docker run.
ENTRYPOINTThe executable that always runsWhen the container IS the command (e.g., curl, python).
ENVSet environment variablesConfig that should persist into the running container.
ARGBuild-time variablesValues needed only during build (versions, flags). Not in final image.
EXPOSEDocument a portTell humans/tools which ports the app uses. Does NOT publish.
VOLUMEDeclare a mount pointDirectories that should persist beyond the container lifecycle.
WORKDIRSet working directoryAvoid RUN cd /somewhere && ... — use WORKDIR instead.
USERSwitch to a non-root userSecurity best practice. Set after installing packages.
LABELAdd metadata key-value pairsMaintainer, version, description — for automation and inspection.

4. CMD vs ENTRYPOINT

This is the most commonly confused pair in Dockerfile authoring. Here's the mental model:

  • ENTRYPOINT = the executable (what always runs)
  • CMD = the default arguments (what can be overridden)
# Example: a container that acts like the "curl" command
ENTRYPOINT ["curl"]
CMD ["--help"]

# docker run mycurl             → runs: curl --help
# docker run mycurl example.com → runs: curl example.com
#                                   (CMD is replaced, ENTRYPOINT stays)

Exec Form vs Shell Form

FormSyntaxHow It RunsSignals?
Exec (preferred)CMD ["node", "server.js"]Runs directly as PID 1✅ Receives SIGTERM
ShellCMD node server.jsWrapped in /bin/sh -c❌ Shell is PID 1, app doesn't get signals
Always Use Exec Form

Shell form wraps your command in /bin/sh -c "...". This means your app is NOT PID 1, won't receive SIGTERM on docker stop, and will be forcefully killed after the grace period. Always prefer CMD ["executable", "arg1"].

5. Build Context

When you run docker build ., the entire directory (the "build context") is packaged as a tarball and sent to the Docker daemon. This happens BEFORE any instruction executes.

$ docker build .
Sending build context to Docker daemon  245.8MB  ← Ouch!

If your project has node_modules/, .git/, test data, or large assets, they all get sent — even if no COPY instruction references them.

The .dockerignore File

Create a .dockerignore at the project root (same syntax as .gitignore):

node_modules
.git
*.log
dist
coverage
.env
.DS_Store

Benefits:

  • Faster builds — less data sent to the daemon
  • Smaller imagesCOPY . . won't include ignored files
  • Security — keeps .env files and secrets out of images

6. Building the Image

$ docker build -t myapp:1.0 .
PartMeaning
docker buildThe build command
-t myapp:1.0Tag the resulting image as myapp with version 1.0
.The build context — current directory. Sends its contents to the daemon.

What Happens During Build

  1. Docker packages the build context (respecting .dockerignore) and sends it to the daemon.
  2. Each instruction is executed in order, creating a new intermediate layer.
  3. If a layer hasn't changed since the last build, Docker uses the cache (instant).
  4. The final layer stack is saved as an image with the tag you specified.
$ docker build -t myapp:1.0 .
[+] Building 12.4s (10/10) FINISHED
 => [1/5] FROM node:20-slim@sha256:abc...          0.0s (cached)
 => [2/5] WORKDIR /app                             0.0s (cached)
 => [3/5] COPY package*.json ./                    0.1s
 => [4/5] RUN npm ci --production                 10.2s
 => [5/5] COPY . .                                 0.3s
 => exporting to image                             1.8s
 => => naming to docker.io/library/myapp:1.0       0.0s

7. Common Mistakes

❌ Mistake 1: Separate apt-get update and install

# BAD — update layer gets cached, install uses stale index
RUN apt-get update
RUN apt-get install -y curl

# GOOD — single layer, always fresh
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

❌ Mistake 2: Copying everything before installing dependencies

# BAD — any source code change busts the npm install cache
COPY . .
RUN npm ci

# GOOD — deps are cached until package.json changes
COPY package*.json ./
RUN npm ci
COPY . .

❌ Mistake 3: Running as root

# BAD — container runs as root (security risk)
CMD ["node", "server.js"]

# GOOD — create and switch to non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser
CMD ["node", "server.js"]

Interactive Quizzes

Quiz 1: CMD vs ENTRYPOINT

Given this Dockerfile:

ENTRYPOINT ["python"]
CMD ["app.py"]

What command runs when you execute docker run myimage script.py?

Quiz 2: Layer Creation

Which instructions create a new filesystem layer?

FROM ubuntu:22.04
ENV APP_PORT=3000
RUN apt-get update && apt-get install -y curl
COPY . /app
EXPOSE 8080
CMD ["./start.sh"]

Quiz 3: Build Context

Your project is 500 MB. Your Dockerfile only uses COPY src/ /app/src/ (the src/ folder is 2 MB). Without a .dockerignore, how much data is sent to the Docker daemon?

Hands-On Tasks

Task 1: Dockerize a Flask App

Create a minimal Flask application and build it into a Docker image.

# 1. Create project directory
mkdir flask-app && cd flask-app

# 2. Create app.py
cat > app.py << 'EOF'
from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello from Docker!"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
EOF

# 3. Create requirements.txt
echo "flask==3.0.0" > requirements.txt

# 4. Write the Dockerfile
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
USER nobody
CMD ["python", "app.py"]
EOF

# 5. Build it
docker build -t flask-app:1.0 .

# 6. Run it
docker run -d -p 5000:5000 flask-app:1.0

# 7. Test it
curl http://localhost:5000
# → Hello from Docker!
Task 2: Measure .dockerignore Impact

See how much .dockerignore affects build context size and image size.

# In the flask-app directory, simulate a large project:
mkdir -p data && dd if=/dev/zero of=data/bigfile.bin bs=1M count=100

# Build WITHOUT .dockerignore — watch context size:
docker build -t flask-no-ignore:1.0 .
# "Sending build context to Docker daemon  104.9MB"

# Check image size:
docker images flask-no-ignore:1.0 --format '{{.Size}}'
# → ~230 MB (100 MB of junk included!)

# Now create .dockerignore:
cat > .dockerignore << 'EOF'
data/
__pycache__
*.pyc
.git
.env
EOF

# Rebuild:
docker build -t flask-with-ignore:1.0 .
# "Sending build context to Docker daemon  4.096kB"

# Compare sizes:
docker images --format '{{.Repository}}:{{.Tag}} → {{.Size}}' | grep flask
# flask-no-ignore:1.0  → ~230 MB
# flask-with-ignore:1.0 → ~130 MB
🌍 Not Just Docker

Buildah can build images from standard Dockerfiles without requiring a running Docker daemon — useful in CI/CD environments and rootless builds. Cloud Native Buildpacks skip Dockerfiles entirely: they auto-detect your language, install dependencies, and produce an OCI image with best-practice layers — no Dockerfile authoring required. The Dockerfile format is dominant but not the only path to a container image.

🔑 Key Takeaways

  • A Dockerfile is a recipe — ordered instructions, each creating a layer. It's plain text, version-controlled, and reproducible.
  • Order matters for caching — put things that change least (base image, deps) at the top; things that change most (source code) at the bottom.
  • ENTRYPOINT = the executable; CMD = default arguments. Use exec form ["..."] so your process is PID 1 and receives signals.
  • The build context is everything — the entire directory gets sent to the daemon. Use .dockerignore to keep it small and secure.
  • Only FROM, RUN, COPY, ADD create filesystem layers. Other instructions add metadata only.
  • Combine related commands in a single RUN to avoid stale caches and reduce layers.
  • Don't run as root — add a USER instruction after installing packages.