Home DevOps Dockerfile Explained: Instructions, Layers & Real-World Patterns

Dockerfile Explained: Instructions, Layers & Real-World Patterns

In Plain English 🔥
Imagine you're moving to a new city and need to set up your apartment exactly the way you like it. Instead of doing it from memory every time, you write a step-by-step instruction sheet: 'Step 1 — buy a bed frame. Step 2 — assemble it. Step 3 — put the mattress on top.' A Dockerfile is exactly that instruction sheet, but for your application's environment. Docker reads it top to bottom and builds a perfect, repeatable copy of your app's home — every single time, on any machine in the world.
⚡ Quick Answer
Imagine you're moving to a new city and need to set up your apartment exactly the way you like it. Instead of doing it from memory every time, you write a step-by-step instruction sheet: 'Step 1 — buy a bed frame. Step 2 — assemble it. Step 3 — put the mattress on top.' A Dockerfile is exactly that instruction sheet, but for your application's environment. Docker reads it top to bottom and builds a perfect, repeatable copy of your app's home — every single time, on any machine in the world.

Every developer has lived through the 'works on my machine' nightmare. You ship code that runs perfectly on your laptop, but the moment it lands on a colleague's computer or a production server, something breaks — a different Python version, a missing library, an environment variable pointing nowhere. This isn't bad luck; it's the inevitable result of environments that drift over time. Dockerfiles exist specifically to kill this problem at the root.

A Dockerfile is a plain-text script that defines every single thing your application needs to run — the base operating system, the runtime, the dependencies, the files, the startup command. Docker reads this file and builds an image: a portable, immutable snapshot of your application's complete environment. That image runs identically on a MacBook, a Linux CI server, or a Kubernetes cluster in the cloud. The environment stops being a variable.

By the end of this article you'll understand not just what each Dockerfile instruction does, but why it exists and when to reach for it. You'll know how Docker's layer cache works and how to exploit it to shave minutes off your build times. You'll be able to write a production-grade multi-stage Dockerfile that produces a lean, secure image — and you'll know the three mistakes that quietly wreck most Dockerfiles written by developers who only learned the syntax.

How Docker Builds an Image — Layers Are Everything

Before you write a single Dockerfile instruction, you need a mental model of what Docker is actually doing when it reads your file. Docker doesn't build one monolithic blob. It builds a stack of read-only layers, one per instruction. Each layer is a diff — only the filesystem changes from that step.

Why does this matter? Because Docker caches every layer. If you rebuild an image and nothing changed in a particular step, Docker reuses the cached layer instead of running it again. This turns a 3-minute build into a 4-second build. But the cache is sequential — as soon as one layer is invalidated (because something changed), every layer after it is also invalidated and rebuilt from scratch.

This single insight drives the most important Dockerfile design decision you'll ever make: order your instructions from least-likely-to-change to most-likely-to-change. Your base OS almost never changes. Your system dependencies change occasionally. Your app's package dependencies change sometimes. Your source code changes constantly. Structure your Dockerfile in that order and you'll get near-instant cached rebuilds during development.

Think of layers like a stack of transparent slides on an overhead projector. Each slide adds something. You can swap out the top slide without reprinting all the slides beneath it.

Dockerfile.layer-demo · DOCKERFILE
12345678910111213141516171819202122232425262728293031
# Layer 1Base image pulled from Docker Hub.
# This layer is cached after the first pull and almost never changes.
FROM node:20-alpine

# Layer 2Set the working directory inside the container.
# All subsequent instructions run relative to this path.
WORKDIR /app

# Layer 3Copy ONLY the dependency manifest files first.
# Separating this from the source code is the key cache optimization.
# This layer only rebuilds when package.json or the lockfile changes.
COPY package.json package-lock.json ./

# Layer 4Install dependencies.
# Because we copied manifests separately above, npm install only re-runs
# when a dependency actually changes — not every time you edit a .js file.
RUN npm ci --omit=dev

# Layer 5Now copy the actual source code.
# This layer changes on every code edit, but that's fine because
# the expensive npm install layer above is still cached.
COPY src/ ./src/

# Layer 6Declare the port the app listens on (documentation only —
# EXPOSE does NOT actually publish the port to the host).
EXPOSE 3000

# Layer 7The default command to start the application.
# Using the JSON array (exec) form avoids spawning a shell,
# which means SIGTERM signals reach your Node process directly.
CMD ["node", "src/index.js"]
▶ Output
$ docker build -t my-node-app:1.0 .
[+] Building 42.3s (8/8) FINISHED
=> [1/6] FROM node:20-alpine 12.1s
=> [2/6] WORKDIR /app 0.1s
=> [3/6] COPY package.json package-lock.json ./ 0.1s
=> [4/6] RUN npm ci --omit=dev 28.4s
=> [5/6] COPY src/ ./src/ 0.2s
=> [6/6] EXPOSE 3000 0.0s
=> exporting to image 1.4s

# Now edit src/index.js and rebuild:
$ docker build -t my-node-app:1.1 .
[+] Building 1.2s (8/8) FINISHED
=> [1/6] FROM node:20-alpine CACHED
=> [2/6] WORKDIR /app CACHED
=> [3/6] COPY package.json ... CACHED
=> [4/6] RUN npm ci --omit=dev CACHED ← 28 seconds saved!
=> [5/6] COPY src/ ./src/ 0.2s ← only this layer rebuilt
=> exporting to image 0.8s
⚠️
Cache Trick:Always COPY your dependency manifest (package.json, requirements.txt, go.mod, pom.xml) in its own layer BEFORE copying source code. This single change can save minutes on every local rebuild during development — because dependencies only reinstall when they actually change.

The Instructions That Actually Matter — And What They're Really Doing

There are 18 Dockerfile instructions. In practice, you'll use about 10 of them regularly. Rather than listing all 18 mechanically, let's focus on the ones that cause confusion or have non-obvious behaviour — because those are the ones that bite you in production.

FROM is always first. It picks your starting layer. FROM scratch gives you an empty image — useful for compiled Go or Rust binaries. FROM node:20-alpine gives you Node on Alpine Linux, which is ~7MB versus ~180MB for Debian-based images. Prefer Alpine for production; prefer the fuller images when you need debugging tools.

RUN executes a shell command during the build. Each RUN creates a new layer. Chain related commands with && and clean up in the same RUN to avoid bloating the image with intermediate files that persist in a layer even after you delete them later.

COPY vs ADD: Use COPY almost always. ADD does extra magic — it auto-extracts tar archives and can fetch URLs — but that magic makes builds unpredictable. Use ADD only when you explicitly need its archive extraction feature.

ENV sets environment variables available at both build time and runtime. ARG sets variables available only at build time. Never put secrets in ENV — they're visible in docker inspect and image history. Use runtime secret injection instead.

ENTRYPOINT vs CMD: ENTRYPOINT sets the executable that always runs. CMD provides default arguments to it. When you run docker run my-image --verbose, that --verbose replaces CMD but gets appended to ENTRYPOINT. Together they let you build images that behave like CLI tools.

Dockerfile.instructions-deep-dive · DOCKERFILE
123456789101112131415161718192021222324252627282930313233343536373839
# Build-time variable — available only during docker build, not at runtime.
# Pass it with: docker build --build-arg APP_VERSION=2.1.0 .
ARG APP_VERSION=1.0.0

FROM python:3.12-slim

WORKDIR /api

# Runtime environment variable — visible to the running container.
# Safe for non-sensitive config like port numbers or log levels.
ENV LOG_LEVEL=info \
    PORT=8080

# Chain RUN commands with && to keep this as ONE layer.
# The final rm -rf cleans up apt cache IN THE SAME LAYER so it doesn't
# persist and bloat the image. If you ran rm -rf in a separate RUN,
# the cache would still exist in the previous layer — wasted space.
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

# Copy dependency file alone first (cache optimization from section above)
COPY requirements.txt .

# Install Python deps. --no-cache-dir prevents pip from storing
# the download cache inside the image layer — saves ~50MB.
RUN pip install --no-cache-dir -r requirements.txt

# Copy application source code
COPY app/ ./app/

# ENTRYPOINT sets the fixed executable — this always runs.
# Using exec form (JSON array) so the process receives OS signals directly.
ENTRYPOINT ["python", "-m", "uvicorn"]

# CMD provides the default arguments to ENTRYPOINT.
# You can override these at runtime without changing ENTRYPOINT:
# docker run my-api app.main:app --port 9000
CMD ["app.main:app", "--host", "0.0.0.0", "--port", "8080"]
▶ Output
$ docker build -t my-python-api:latest .
[+] Building 38.7s (9/9) FINISHED

# Default startup (uses CMD arguments):
$ docker run --rm -p 8080:8080 my-python-api:latest
INFO: Started server process [1]
INFO: Uvicorn running on http://0.0.0.0:8080

# Override CMD arguments without touching ENTRYPOINT:
$ docker run --rm -p 9000:9000 my-python-api:latest app.main:app --host 0.0.0.0 --port 9000
INFO: Uvicorn running on http://0.0.0.0:9000

# Check image size — slim base + no-cache-dir pays off:
$ docker images my-python-api
REPOSITORY TAG IMAGE ID SIZE
my-python-api latest a3f91b2cd4e1 187MB
⚠️
Watch Out:Never store secrets (API keys, passwords, tokens) in ENV or ARG instructions. Both are permanently visible in the image layer history via `docker history --no-trunc my-image`. Use Docker secrets, environment injection at runtime, or a secrets manager like AWS Secrets Manager instead.

Multi-Stage Builds — The Pattern That Separates Pros from Beginners

Here's a scenario every developer hits: you need a compiler or build tool to produce your application binary, but you don't need that compiler in the final image running in production. Shipping the compiler anyway means a larger attack surface, a bigger image pulling over the network, and slower startup times in Kubernetes.

Multi-stage builds solve this elegantly. You define multiple FROM blocks in one Dockerfile. Each FROM starts a fresh image context. You build your application in an early 'builder' stage that has all the tools, then you COPY only the compiled output into a final, minimal 'runtime' stage. The builder stage is discarded — it never ships.

This pattern is transformative for compiled languages. A Go application that builds in a 800MB image with all the Go toolchain can ship as a 12MB Alpine or even a 3MB scratch image containing just the binary. But it's equally powerful for JavaScript — build your React app with node_modules in one stage, then copy only the /dist folder into an nginx image.

The key instruction is COPY --from=builder. The name builder is just a label you assign with AS in the FROM line. You can have as many stages as you need, and any stage can copy from any previous stage. You can even reference external images as copy sources with --from=nginx:alpine.

Dockerfile.multi-stage-go · DOCKERFILE
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
# ─── Stage 1: Builder ──────────────────────────────────────────────────────
# This stage has the full Go toolchain (~800MB). It compiles our app.
# The 'AS builder' label lets us reference this stage later.
FROM golang:1.22-alpine AS builder

# Install git — needed if any Go modules pull from private repos
RUN apk add --no-cache git

WORKDIR /build

# Copy go module files first for cache optimization
COPY go.mod go.sum ./

# Download dependencies — this layer is cached until go.mod changes
RUN go mod download

# Copy all source code
COPY . .

# Build the binary.
# CGO_ENABLED=0 — statically link everything, no C runtime needed.
# GOOS=linux — compile for Linux even if building on a Mac.
# -ldflags "-w -s" — strip debug info and symbol table (~30% size reduction).
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -o /build/api-server \
    ./cmd/server

# ─── Stage 2: Runtime ──────────────────────────────────────────────────────
# 'scratch' is a completely empty image — no OS, no shell, nothing.
# The only thing in this final image is our compiled binary.
# This is as lean and secure as it gets.
FROM scratch AS runtime

# Copy TLS certificates from the builder stage so our app can make
# HTTPS calls. Without this, any TLS connection would fail.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy ONLY the compiled binary from the builder stage.
# Everything else from the 800MB build environment is discarded.
COPY --from=builder /build/api-server /api-server

EXPOSE 8080

# No shell in scratch, so we must use exec form
ENTRYPOINT ["/api-server"]
▶ Output
$ docker build -t go-api:production .
[+] Building 54.2s (12/12) FINISHED
=> [builder 1/7] FROM golang:1.22-alpine 18.3s
=> [builder 4/7] RUN go mod download 9.1s
=> [builder 6/7] RUN CGO_ENABLED=0 ... 22.4s
=> [runtime 1/1] FROM scratch 0.0s
=> [runtime 2/2] COPY --from=builder ... 0.1s
=> exporting to image 0.3s

# Compare image sizes — this is the payoff:
$ docker images | grep go-api
REPOSITORY TAG SIZE
go-api production 11.2MB ← final image shipped to production
go-api builder 847MB ← never leaves your build machine

# Run it:
$ docker run --rm -p 8080:8080 go-api:production
2024/01/15 10:23:01 API server listening on :8080
🔥
Interview Gold:When an interviewer asks 'how do you reduce Docker image size?', multi-stage builds is the answer they're looking for — not 'use Alpine'. Explain that Alpine helps (180MB → 7MB base), but multi-stage builds are what lets you ship a 12MB production image from an 800MB build environment by discarding the toolchain entirely.

Production-Ready Dockerfile — Putting It All Together

Knowing individual instructions is one thing. Knowing how they compose into a secure, efficient, production-grade Dockerfile is what makes the difference in a real project. There are four production concerns beyond 'does it build': image size, security, build speed, and signal handling.

Image size: use a minimal base, chain RUN commands, use multi-stage builds, and add a .dockerignore file — this is the most commonly forgotten file. Without it, COPY . . sends your entire project directory (including node_modules, .git, test fixtures) to the Docker build context, which can make builds take minutes before a single instruction executes.

Security: never run as root. Add a non-root user with RUN addgroup and adduser, then switch to it with USER. If an attacker compromises your app, running as a non-root user limits the blast radius significantly.

Signal handling: always use exec form ["executable", "arg"] for CMD and ENTRYPOINT — not shell form executable arg. Shell form wraps your command in /bin/sh -c, which means your process gets PID 2, not PID 1. Kubernetes and Docker send SIGTERM to PID 1 when stopping a container. If your app isn't PID 1, it never receives the signal and gets hard-killed after the timeout.

Build speed: everything from section one — order layers by change frequency, separate dependency manifests from source code.

Dockerfile.production-ready · DOCKERFILE
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
# ─── .dockerignore (create this file alongside your Dockerfile) ────────────
# node_modules/
# .git/
# .github/
# coverage/
# *.test.js
# .env*
# README.md
# docker-compose*.yml
# ─────────────────────────────────────────────────────────────────────────────

# ─── Stage 1: Dependency installation ────────────────────────────────────────
FROM node:20-alpine AS deps

# Create a non-root user early — we'll reuse this uid in the runtime stage.
# Using a numeric UID (1001) instead of a name is more portable across images.
RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 --ingroup appgroup appuser

WORKDIR /app

# Copy only manifests — cache this expensive layer aggressively
COPY package.json package-lock.json ./

# ci is stricter than install — it fails if lockfile is out of sync,
# which catches dependency drift bugs in CI before they hit production.
RUN npm ci --omit=dev

# ─── Stage 2: Build (for TypeScript/React projects that need transpilation) ──
FROM node:20-alpine AS build

WORKDIR /app

# Copy deps from previous stage (avoids re-installing)
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Run the build step (TypeScript compile, bundling, etc.)
RUN npm run build

# ─── Stage 3: Production runtime ─────────────────────────────────────────────
FROM node:20-alpine AS production

WORKDIR /app

# Copy the non-root user definitions from the deps stage
COPY --from=deps /etc/passwd /etc/passwd
COPY --from=deps /etc/group /etc/group

# Copy only what production needs — nothing from build tools or dev deps
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .

# Switch to non-root user BEFORE the final CMD.
# Everything after this line runs as appuser, not root.
USER appuser

EXPOSE 3000

# Exec form — process receives signals directly as PID 1.
# No shell wrapper means clean shutdown when Kubernetes sends SIGTERM.
CMD ["node", "dist/index.js"]
▶ Output
$ docker build --target production -t my-app:prod .
[+] Building 23.1s (14/14) FINISHED

$ docker run --rm -p 3000:3000 my-app:prod
Server running on port 3000

# Verify the process is NOT running as root:
$ docker run --rm my-app:prod whoami
appuser

# Verify PID 1 is your app (not a shell):
$ docker run --rm my-app:prod ps aux
PID USER COMMAND
1 appuser node dist/index.js ← PID 1, will receive SIGTERM correctly

# Lean final image:
$ docker images my-app:prod
REPOSITORY TAG SIZE
my-app prod 142MB
⚠️
Watch Out:The .dockerignore file is not optional in production. Without it, `COPY . .` includes node_modules (potentially 500MB+), .git history, .env files with real secrets, and test data — all baked into your image and potentially pushed to a public registry. Create .dockerignore before you write the first COPY instruction.
AspectShell Form (RUN command arg)Exec Form (RUN ["command", "arg"])
SyntaxCMD node server.jsCMD ["node", "server.js"]
Process spawningRuns inside /bin/sh -c — your app is a child processRuns directly — your app IS the process
PID in containerYour app gets PID 2 or higherYour app gets PID 1
Signal handlingSIGTERM from Docker/K8s may not reach your appSIGTERM reaches your app directly — clean shutdown works
Shell features availableYes — variable expansion, pipes, &&No — must handle logic in the command itself
Best used forRUN instructions that need shell featuresCMD and ENTRYPOINT — always prefer this
RiskGraceful shutdown often silently brokenMinimal — this is the safe default

🎯 Key Takeaways

  • Docker builds images as a stack of cached layers — order your COPY and RUN instructions from least-to-most frequently changing, always copying dependency manifests before source code, to get near-instant cached rebuilds.
  • Always use exec form (JSON array syntax) for CMD and ENTRYPOINT — shell form wraps your process in /bin/sh -c, bumping it to PID 2 and silently breaking graceful shutdown in Docker and Kubernetes.
  • Multi-stage builds let you use a full toolchain (800MB) during compilation and ship only the compiled output (12MB) to production — the build stage is discarded and never pushed to a registry.
  • The .dockerignore file is mandatory, not optional — without it, COPY . . silently bakes node_modules, .git history, and .env files into your image; add it before you write your first COPY instruction.

⚠ Common Mistakes to Avoid

  • Mistake 1: Copying source code before installing dependencies — Symptom: every code change triggers a full npm install or pip install, making rebuilds take 2-5 minutes even when dependencies didn't change — Fix: always COPY the dependency manifest (package.json, requirements.txt) in its own layer and run the install command before you COPY the rest of your source code.
  • Mistake 2: Using shell form for CMD and ENTRYPOINT — Symptom: your container takes 10-30 seconds to stop (Docker's default timeout) instead of shutting down instantly, and graceful shutdown hooks in your application never fire — Fix: switch to exec (JSON array) form: change CMD node server.js to CMD ["node", "server.js"] so your process becomes PID 1 and receives OS signals directly.
  • Mistake 3: Running the container as root — Symptom: no immediate error, but a compromised container has unrestricted access to the container filesystem and any mounted volumes — Fix: add RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser and then USER appuser before your CMD. Verify with docker run --rm your-image whoami — it should return your non-root username.

Interview Questions on This Topic

  • QWhat's the difference between CMD and ENTRYPOINT, and can you give a real example of when you'd use both together in the same Dockerfile?
  • QIf I change one line in my source code and rebuild, which layers get rebuilt and why? How would you structure a Dockerfile to make that rebuild as fast as possible?
  • QWhat's the difference between ARG and ENV — and why should you never put a secret in either one? What's the correct alternative?

Frequently Asked Questions

What is the difference between a Dockerfile and a Docker image?

A Dockerfile is the source code — a plain-text instruction file you write and version control. A Docker image is the compiled artifact produced when Docker reads and executes that Dockerfile. The relationship is the same as source code to a compiled binary: you share the Dockerfile, Docker builds the image, and you run containers from the image.

How do I reduce the size of my Docker image?

The three highest-impact changes are: (1) use a minimal base image like Alpine instead of full Debian — this alone drops your base from ~180MB to ~7MB; (2) use multi-stage builds so your build tools and compiler never ship to production; (3) chain RUN commands with && and clean up package manager caches in the same RUN instruction so intermediate files don't persist in a layer.

Why does my container ignore SIGTERM and take 30 seconds to stop?

You're almost certainly using shell form for your CMD or ENTRYPOINT (e.g., CMD node server.js). This wraps your app in /bin/sh -c, making the shell PID 1 and your app PID 2. Docker sends SIGTERM to PID 1 (the shell), which doesn't forward it to your app. After the timeout, Docker sends SIGKILL. Fix it by switching to exec form: CMD ["node", "server.js"].

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousDocker Images and ContainersNext →Docker Volumes and Networking
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged