Most tutorials show you a 5-line Dockerfile and move on. When you deploy to production, you might find your image is 1.2 GB, builds take 8 minutes even for a one-line code change, and your security team flags vulnerabilities in packages you didn’t know were installed. Writing a good Dockerfile is a skill that pays off every time your CI pipeline runs.
Every Dockerfile begins with FROM. It sets the base image that all subsequent instructions build upon.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Use a specific version (recommended)FROM python:3.11-slim# Use a minimal baseFROM alpine:3.18# Use scratch (empty image) for statically compiled binariesFROM scratch# Multiple FROM statements = multi-stage build (covered later)FROM golang:1.21 AS builder# ...FROM alpine:3.18# ...
Best practice: Always pin a specific version. FROM python:latest will break when a new Python version is released and your code isn’t compatible.
RUN executes a command inside the image during the build process. Each RUN creates a new layer.
1
2
3
4
5
6
7
8
9
10
11
12
13
# Shell form (runs in /bin/sh -c)RUN apt-get update && apt-get install -y curl# Exec form (no shell processing)RUN["apt-get", "update"]# Multi-line with && to keep it in one layerRUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/*
Critical pattern: Chain commands with && and clean up in the same RUN instruction. If you apt-get install in one RUN and rm -rf /var/lib/apt/lists/* in another, the package lists still exist in the first layer — layers are additive, never subtractive.
1
2
3
4
5
6
7
8
9
# BAD: 3 layers, cleanup doesn't help (lists persist in Layer 1)RUN apt-get updateRUN apt-get install -y curlRUN rm -rf /var/lib/apt/lists/*# GOOD: 1 layer, lists are removed before the layer is committedRUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# COPY: Copy files from build context into the imageCOPY requirements.txt /app/requirements.txtCOPY . /app/# COPY with --chown to set ownershipCOPY --chown=appuser:appuser . /app/# ADD: Like COPY, but also:# - Extracts tar archives automatically# - Supports URLs (but don't use this — use curl in RUN instead)ADD archive.tar.gz /app/
Best practice: Use COPY unless you specifically need tar extraction. COPY is more explicit and predictable.
# Sets the working directory for subsequent instructionsWORKDIR /app# Equivalent to mkdir -p && cd (creates the directory if it doesn't exist)WORKDIR /app/src# These three lines:RUN mkdir -p /app &&cd /app && npm install# Are better written as:WORKDIR /appRUN npm install
# ENV: Set environment variables (persist in the running container)ENVNODE_ENV=production
ENVAPP_PORT=8080# ARG: Build-time variables (NOT available in the running container)ARGPYTHON_VERSION=3.11FROM python:${PYTHON_VERSION}-slim# ARG values can be overridden at build time# docker build --build-arg PYTHON_VERSION=3.12 .
Key differences:
Feature
ENV
ARG
Available during build
Yes
Yes
Available at runtime
Yes
No
Visible in docker inspect
Yes
No (but cached in layers)
Can be overridden at build
No (use ARG for that)
Yes (--build-arg)
Persists across stages
Yes (within a stage)
No (reset at each FROM)
Security warning: Neither ENV nor ARG should contain secrets. Build arguments are stored in the image history (docker history). Use Docker secrets or mount secrets at build time with --secret instead.
# Documents which ports the container listens onEXPOSE 80EXPOSE 443EXPOSE 8080/tcpEXPOSE 8443/udp
EXPOSE does not publish the port. It’s documentation. You still need -p 8080:80 when running the container. Think of it as a comment that tooling can read.
These two instructions define what happens when the container starts. Understanding the difference is crucial.
1
2
3
4
5
6
7
8
9
# CMD: Default command (can be overridden entirely)CMD["python3","app.py"]# ENTRYPOINT: Fixed executable (arguments can be appended)ENTRYPOINT["python3","app.py"]# Combined: ENTRYPOINT is the executable, CMD provides default argumentsENTRYPOINT["python3"]CMD["app.py"]
Behavior with docker run:
Dockerfile
docker run image
docker run image bash
CMD ["python3", "app.py"]
Runs python3 app.py
Runs bash (CMD replaced)
ENTRYPOINT ["python3", "app.py"]
Runs python3 app.py
Runs python3 app.py bash (appended)
ENTRYPOINT ["python3"] + CMD ["app.py"]
Runs python3 app.py
Runs python3 bash (CMD replaced)
Shell form vs exec form:
1
2
3
4
5
6
# Exec form (preferred) — runs directly, receives signals properlyCMD["python3","app.py"]# Shell form — runs as /bin/sh -c "python3 app.py"# The shell wraps your process, so SIGTERM goes to sh, not your appCMD python3 app.py
Always use exec form (["executable", "arg1", "arg2"]) for CMD and ENTRYPOINT. Shell form wraps your process in /bin/sh -c, which prevents proper signal handling and can cause issues with graceful shutdown.
# Check if the web server is respondingHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3\
CMD curl -f http://localhost:8080/health ||exit1# Disable healthcheck inherited from base imageHEALTHCHECK NONE
Parameter
Default
Meaning
--interval
30s
Time between checks
--timeout
30s
Max time for a single check
--start-period
0s
Grace period before first check
--retries
3
Consecutive failures before “unhealthy”
Docker marks the container as healthy, unhealthy, or starting based on the healthcheck results. Orchestrators (Docker Compose, Kubernetes) use this to decide whether to route traffic to the container.
# app.pyfromflaskimportFlask,jsonifyimportredisapp=Flask(__name__)cache=redis.Redis(host='redis',port=6379)@app.route('/')defhello():count=cache.incr('hits')returnjsonify(message='Hello from Docker!',visits=count)if__name__=='__main__':app.run(host='0.0.0.0',port=5000)
The .dockerignore file works like .gitignore but for the Docker build context. When you run docker build ., Docker sends the entire directory (the “build context”) to the daemon. Without .dockerignore, this includes everything.
Without a .dockerignore, a project with a 500 MB .git directory and a 200 MB node_modules sends 700 MB to the daemon on every build — even if none of those files are used in the image.
Docker caches each layer. If an instruction hasn’t changed (and all preceding layers are cached), Docker reuses the cached layer. But the moment one layer’s cache is invalidated, all subsequent layers must be rebuilt.
This is why instruction order is critical:
1
2
3
4
5
6
7
8
# BAD ORDER: Any code change invalidates pip install cacheCOPY . /appRUN pip install -r requirements.txt # Rebuilds every time ANY file changes# GOOD ORDER: pip install is cached unless requirements.txt changesCOPY requirements.txt /app/requirements.txtRUN pip install -r requirements.txt # Only rebuilds when dependencies changeCOPY . /app # Code changes only rebuild this layer
The general rule: order instructions from least-frequently-changing to most-frequently-changing.
Multi-stage builds are one of Docker’s most powerful features. They let you use a large image for building (with compilers, build tools, etc.) and copy only the final artifact into a small runtime image.
Go compiles to static binaries, enabling the smallest possible images:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Stage 1: BuildFROM golang:1.21 AS builderWORKDIR /appCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUNCGO_ENABLED=0GOOS=linux go build -o server .# Stage 2: Runtime (scratch = empty image)FROM scratchCOPY --from=builder /app/server /serverCOPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/EXPOSE 8080ENTRYPOINT["/server"]
Size comparison for a Go HTTP server:
1
docker images | grep go-server
1
2
3
REPOSITORY TAG IMAGE ID CREATED SIZE
go-server single a1b2c3d4e5f6 10 seconds ago 845MB
go-server multistage b2c3d4e5f6a7 5 seconds ago 12.4MB
845 MB vs 12.4 MB. The multi-stage build produces an image 68x smaller because it discards the entire Go toolchain — only the compiled binary and CA certificates make it to the final image. The same pattern works for Node.js (build with node:20-alpine, copy only node_modules and app code to the runtime stage) and any other language.
A common confusion point. Here’s a practical example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Build-time configuration with ARGARGNODE_ENV=production
ARGAPP_VERSION=unknown
# Build-time use (determines what gets installed)RUNif["$NODE_ENV"="development"];then\
npm install;\
else\
npm ci --only=production;\
fi# Runtime configuration with ENVENVNODE_ENV=${NODE_ENV}ENVAPP_VERSION=${APP_VERSION}# Now NODE_ENV is available both during build AND at runtime
# BAD: Different results on different daysRUN apt-get install -y curl# GOOD: ReproducibleRUN apt-get install -y curl=7.88.1-10+deb12u4# GOOD for pipRUN pip install flask==3.0.0 gunicorn==21.2.0# GOOD for npmCOPY package-lock.json .RUN npm ci # Uses lockfile for exact versions
# NEVER DO THIS — the password is stored in the image historyARG DB_PASSWORDRUNecho"password=$DB_PASSWORD" > /app/config# Instead, use BuildKit secretsRUN --mount=type=secret,id=db_password \
cat /run/secrets/db_password > /app/config
You can now write Dockerfiles that produce small, secure, fast-building images. But a container in isolation isn’t very useful — it needs to communicate with the outside world and persist data. The next article covers Docker networking (how containers talk to each other and the host) and volumes (how data survives container restarts). These are the building blocks you’ll need before we tackle multi-container applications with Docker Compose.