A Dockerfile is just a text file with instructions to build an image. Every line creates a layer. The order we write these instructions matters a LOT for build speed and image size.
The basic instructions
Here’s what the most common instructions do:
- FROM — sets the base image (every Dockerfile starts here)
- WORKDIR — sets the working directory inside the container
- COPY — copies files from our machine into the image
- RUN — executes a command during build (install packages, compile code)
- EXPOSE — documents which port the app listens on (doesn’t actually publish it)
- ENV — sets environment variables
- CMD — the default command when the container starts
- ENTRYPOINT — like CMD, but harder to override
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
CMD vs ENTRYPOINT
This trips people up. Both define what runs when the container starts, but they behave differently.
CMD — provides a default command that can be easily overridden by passing arguments to docker run.
ENTRYPOINT — sets the main executable. Arguments passed to docker run get appended to it.
In simple language, think of ENTRYPOINT as the verb and CMD as the default arguments. We usually combine them when we want a fixed command but flexible arguments.
# CMD only — can be fully overridden
CMD ["node", "server.js"]
# docker run my-app → node server.js
# docker run my-app node other.js → node other.js (overridden)
# ENTRYPOINT + CMD — fixed command, default args
ENTRYPOINT ["node"]
CMD ["server.js"]
# docker run my-app → node server.js
# docker run my-app other.js → node other.js (args appended)
Layer caching — order matters
Docker caches layers from top to bottom. The moment a layer changes, everything below it is rebuilt. So we want to put things that change frequently at the bottom.
This is why we copy package.json first and run npm install before copying our source code. Dependencies don’t change often, but our code does. This way, npm install is cached on most builds.
# Good — dependencies cached separately from source code
COPY package*.json ./
RUN npm ci
COPY . .
# Bad — any code change invalidates npm install cache
COPY . .
RUN npm ci
Multi-stage builds
Multi-stage builds let us use one image for building and a different (smaller) one for running. The build artifacts are copied between stages, but build tools don’t end up in the final image.
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production (much smaller image)
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
The final image only has nginx and our built files. Node.js, npm, and all dev dependencies are left behind in the builder stage.
Reducing image size
Every megabyte matters. Smaller images mean faster pulls, faster deploys, and less attack surface.
# Use alpine base images (5MB vs 900MB+)
FROM node:20-alpine
# Combine RUN commands to reduce layers
RUN apk add --no-cache git curl && \
rm -rf /var/cache/apk/*
# Use .dockerignore to exclude junk from COPY
# .dockerignore file:
# node_modules
# .git
# *.md
# .env
Security basics
Running as root inside a container is a bad idea. If an attacker breaks out, they’re root on the host too.
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
# Switch to non-root user
USER node
CMD ["node", "server.js"]
Other security rules:
- Never put secrets in the Dockerfile — no
ENV API_KEY=abc123. Use runtime env vars or secret managers instead. - Use specific image tags —
node:20.11.1-alpinenotnode:latest. - Scan images —
docker scout cves my-imageto find vulnerabilities.