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"]
| Line | Instruction | What It Does |
|---|---|---|
| 1 | FROM node:20-slim | Sets the base image. Every Dockerfile must start with FROM. node:20-slim is a minimal Debian image with Node.js 20 pre-installed. |
| 2 | WORKDIR /app | Sets the working directory inside the container. All subsequent commands run relative to /app. Creates the directory if it doesn't exist. |
| 3 | COPY package*.json ./ | Copies package.json and package-lock.json from host into the image. Done separately so this layer is cached unless dependencies change. |
| 4 | RUN npm ci --production | Executes a command at build time. Installs dependencies. Creates a new layer with node_modules/. |
| 5 | COPY . . | Copies the rest of the application source code. Done AFTER npm ci to preserve the dependency cache. |
| 6 | EXPOSE 3000 | Documents which port the app listens on. Does NOT actually publish the port — that's -p at runtime. |
| 7 | CMD ["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
3. Key Instructions Reference
| Instruction | Purpose | When to Use |
|---|---|---|
FROM | Set base image | Always first line. Multi-stage builds can have multiple FROMs. |
RUN | Execute a command at build time | Install packages, compile code, create directories. |
COPY | Copy files/dirs from build context into image | Your app code, config files. Preferred over ADD. |
ADD | Like COPY but can extract .tar and fetch URLs | Only when you need auto-extraction. Otherwise use COPY. |
CMD | Default command/arguments | What runs when no command is specified at docker run. |
ENTRYPOINT | The executable that always runs | When the container IS the command (e.g., curl, python). |
ENV | Set environment variables | Config that should persist into the running container. |
ARG | Build-time variables | Values needed only during build (versions, flags). Not in final image. |
EXPOSE | Document a port | Tell humans/tools which ports the app uses. Does NOT publish. |
VOLUME | Declare a mount point | Directories that should persist beyond the container lifecycle. |
WORKDIR | Set working directory | Avoid RUN cd /somewhere && ... — use WORKDIR instead. |
USER | Switch to a non-root user | Security best practice. Set after installing packages. |
LABEL | Add metadata key-value pairs | Maintainer, 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
| Form | Syntax | How It Runs | Signals? |
|---|---|---|---|
| Exec (preferred) | CMD ["node", "server.js"] | Runs directly as PID 1 | ✅ Receives SIGTERM |
| Shell | CMD node server.js | Wrapped in /bin/sh -c | ❌ Shell is PID 1, app doesn't get signals |
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 images —
COPY . .won't include ignored files - Security — keeps
.envfiles and secrets out of images
6. Building the Image
$ docker build -t myapp:1.0 .
| Part | Meaning |
|---|---|
docker build | The build command |
-t myapp:1.0 | Tag the resulting image as myapp with version 1.0 |
. | The build context — current directory. Sends its contents to the daemon. |
What Happens During Build
- Docker packages the build context (respecting
.dockerignore) and sends it to the daemon. - Each instruction is executed in order, creating a new intermediate layer.
- If a layer hasn't changed since the last build, Docker uses the cache (instant).
- 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
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!
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
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
.dockerignoreto 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.