Skip to main content

Dockerfile

A text file containing ordered instructions that Docker executes to build an image layer by layer. Each instruction creates a new filesystem layer — FROM sets the base, RUN executes commands, COPY adds files, and CMD defines the startup command.

Dockerfile — The Recipe for Building Docker Images

What Is a Dockerfile in Simple Terms?

A Dockerfile is a plain text file that tells Docker how to build an image. Think of it as a recipe — it lists the ingredients (base image, files, packages) and the steps (install, configure, copy) needed to produce a working application image. Docker follows these instructions top to bottom, creating one filesystem layer per instruction, until the final image is ready.

Every Docker image starts with a Dockerfile. When you run docker build ., Docker reads the Dockerfile in the current directory and produces an image.

Bash
+------------------------------------------+
| Dockerfile instructions (top to bottom) |
| |
| FROM node:20-alpine <- Layer 1 |
| WORKDIR /app <- Layer 2 |
| COPY package.json ./ <- Layer 3 |
| RUN npm install <- Layer 4 |
| COPY . . <- Layer 5 |
| CMD ["node", "server"] <- Layer 6 |
+------------------------------------------+
|
docker build
|
v
+------------------------------------------+
| Docker Image |
| Layer 1: node:20-alpine base |
| Layer 2: /app directory |
| Layer 3: package.json file |
| Layer 4: node_modules/ |
| Layer 5: application source code |
| Layer 6: default command configuration |
+------------------------------------------+

Every Major Dockerfile Instruction Explained

Dockerfile
# FROM — the base image (required, must be first)
FROM node:20-alpine
# Always pin to a specific version — never FROM node:latest
# WORKDIR — sets the working directory for all subsequent instructions
WORKDIR /app
# Creates the directory if it does not exist
# Equivalent to: mkdir -p /app && cd /app
# COPY — copies files from the build context into the image
COPY package.json package-lock.json ./
# src (on host) -> dest (in image)
# Always prefer COPY over ADD for local files
# ADD — like COPY but also auto-extracts tarballs and accepts URLs
ADD https://example.com/config.tar.gz /etc/
# Use only when you specifically need extraction or URL fetching
# RUN — executes a command during the build
RUN npm ci --omit=dev
# Creates a new layer with the result of the command
# Chain commands to reduce layers:
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*
# ENV — sets environment variables in the image (visible at runtime)
ENV NODE_ENV=production
ENV PORT=8080
# Never put secrets here — visible in docker inspect and docker history
# ARG — build-time variable, NOT available at runtime
ARG BUILD_VERSION=unknown
# Use: docker build --build-arg BUILD_VERSION=v1.0.0 .
# ARG values appear in docker history — NOT safe for secrets
# EXPOSE — documents which port the container listens on
EXPOSE 8080
# Documentation only — does NOT publish the port
# Still need -p 8080:8080 in docker run
# USER — switch to a non-root user
USER node
# Always set this before CMD/ENTRYPOINT
# All subsequent RUN, CMD, ENTRYPOINT run as this user
# HEALTHCHECK — how Docker checks if the container is healthy
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# ENTRYPOINT — the fixed command that always runs
ENTRYPOINT ["node"]
# Cannot be overridden with docker run args (only with --entrypoint flag)
# CMD — default arguments or command
CMD ["dist/server.js"]
# With ENTRYPOINT above: runs node dist/server.js
# Without ENTRYPOINT: runs node dist/server.js directly
# Can be overridden: docker run myimage dist/other.js
# LABEL — metadata key-value pairs
LABEL maintainer="platform@swiggy.com"
LABEL version="v3.1.0"

Layer Caching — The Most Critical Concept

Each instruction creates a cached layer. Docker reuses cached layers if the instruction and its inputs have not changed. If any layer changes, all layers below it are invalidated.

Bash
Cache invalidation cascade:
INSTRUCTION CACHE STATUS REASON
----------- ------------ ------
FROM node:20-alpine CACHE HIT Base image unchanged
WORKDIR /app CACHE HIT Unchanged
COPY package.json . CACHE HIT package.json unchanged
RUN npm install CACHE HIT Same package.json = same output
COPY . . CACHE MISS Source code changed!
RUN npm run build REBUILDING Cache miss propagates downward
Result: npm install (the slow step) is CACHED
Total build time: 20 seconds instead of 3 minutes
If COPY . . came BEFORE npm install:
Source change -> npm install cache bust -> 3 minute build every time

Shell Form vs Exec Form

CMD, RUN, and ENTRYPOINT each support two forms:

Dockerfile
# Shell form — runs through /bin/sh -c
CMD node server.js
# Actual execution: /bin/sh -c "node server.js"
# Problem: node is NOT PID 1 — sh is PID 1
# SIGTERM goes to sh, not to node — graceful shutdown breaks
# Exec form — runs the binary directly (ALWAYS use this for CMD/ENTRYPOINT)
CMD ["node", "server.js"]
# Actual execution: node server.js (directly)
# node IS PID 1 — SIGTERM goes directly to node
# Graceful shutdown works correctly

The .dockerignore File — Always Required

Bash
# .dockerignore — in the same directory as Dockerfile
node_modules/ <- 1GB+ — never copy from host, install inside container
.git/ <- hundreds of MB — version history not needed
*.log <- log files should not be in images
.env <- NEVER copy secrets into images
dist/ <- built output — rebuild inside Docker instead
coverage/ <- test coverage reports
.DS_Store <- macOS metadata
docker-compose.yml <- Compose config not needed in image

A Production-Ready Complete Dockerfile

Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production PORT=8080
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN addgroup -S appgroup && adduser -S appuser -G appgroup && \
chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["node", "dist/server.js"]

Troubleshooting Reference

Problem Cause Fix
Build slow every time COPY . . before RUN npm install Move dependency install before COPY source
Container exits immediately CMD command not found Check binary path with docker run --rm myimage which node
SIGTERM not handled Shell form CMD (CMD node server.js) Use exec form: CMD ["node", "server.js"]
Image too large No .dockerignore, includes node_modules Add .dockerignore, use multi-stage builds
Secrets visible in history Used ENV or ARG for secrets Use BuildKit secret mounts instead
Tip

Use docker build --progress=plain . to see the verbose output with cache hit/miss status for each layer. This is the fastest way to understand why a particular build is slower than expected.

Remember

The order of instructions in a Dockerfile directly controls build speed. Instructions that change rarely (installing system packages, installing production dependencies) go first. Instructions that change often (copying source code) go last. This maximises cache reuse.

Common Mistake

Cleaning up package manager caches in a separate RUN instruction. RUN apt-get install curl followed by RUN rm -rf /var/lib/apt/lists/* does not reduce image size — the cache is baked into the first layer. Always clean in the same RUN: RUN apt-get install curl && rm -rf /var/lib/apt/lists/*.

Security

Never put secrets (API keys, passwords, certificates) into a Dockerfile using ENV or ARG. Both are visible in docker history even if you later delete them with another instruction. Use BuildKit --mount=type=secret for build-time secrets and environment variables or a secret manager for runtime secrets.

Frequently Asked Questions

Why does instruction order in a Dockerfile actually matter for build speed?

Docker caches each layer and reuses it on subsequent builds as long as the instruction and its inputs haven't changed — but the moment one layer's cache is invalidated, every layer after it must rebuild too, even if nothing about them changed. That's why the standard pattern is copying dependency manifests (`package.json`, `requirements.txt`) and running the install step before copying the rest of the application source: source code changes far more often than dependencies, so this ordering keeps the expensive install layer cached across most builds.

What's the most common Dockerfile mistake that bloats final image size unnecessarily?

Using a single-stage build that includes compilers, build tools, and package caches in the final image when only the compiled output is actually needed at runtime — a Go binary or a compiled frontend bundle doesn't need the full SDK sitting in the production image. Multi-stage builds (`FROM golang AS builder`, then a second minimal `FROM` stage that just `COPY --from=builder` the artifact) fix this by discarding everything except what's explicitly copied into the final stage, often cutting image size by 10x or more for compiled languages.