A Dockerfile is a text file with step-by-step instructions that builds a Docker image — the blueprint for a container. Writing the first Dockerfile is a common stumbling point for beginners: which instruction comes first, why the order matters for caching, and whether COPY or ADD is correct. This guide builds a working Dockerfile from zero, explains each instruction, and shows how to avoid the mistakes that cause slow builds and large images.
By the end, a 7-line Dockerfile will be ready to build with docker build and run with docker run, with best practices for making it smaller, faster, and production-ready.
FROM (base image), WORKDIR (work dir), COPY package*.json ./ + RUN npm ci (deps first for cache), COPY . . (source), EXPOSE (port docs), and CMD (run command). Build with docker build -t myapp:1.0 . and run with docker run -p 3000:3000 myapp:1.0. Generate one instantly with a Dockerfile generator without memorizing syntax.
What Is a Dockerfile?
A Dockerfile is a plain-text recipe that Docker uses to build an image automatically. Each instruction in the file creates one cached layer. When the image is run, those layers become a container. Per Dockerfile reference, instructions are executed top to bottom, and the order directly affects build speed due to layer caching.
Unlike a shell script that runs once, a Dockerfile is declarative — it describes the desired state (base OS, dependencies, source, port, command) and Docker reproduces it identically on any machine. This is why "it works on my machine" becomes "it works everywhere."
Anatomy of a Dockerfile — 6 Core Instructions
Answer-first: 6 instructions cover most apps — FROM, WORKDIR, COPY, RUN, EXPOSE, CMD. Each maps to one build step.
| Instruction | What It Does | Example |
|---|---|---|
FROM | Base image (OS + runtime) — must be first | FROM node:20-alpine |
WORKDIR | Creates and sets working directory (mkdir + cd) | WORKDIR /app |
COPY | Copies files from build context into image | COPY package*.json ./ |
RUN | Executes at build time (install deps) | RUN npm ci --only=production |
EXPOSE | Documents container port (does not publish) | EXPOSE 3000 |
CMD | Default command at run time (one per Dockerfile) | CMD ["node", "server.js"] |
Key distinctions:
COPYvsADD— UseCOPY.ADDauto-extracts tar archives and fetches URLs, which is surprising;COPYis explicit per Docker best practices.RUNvsCMD—RUNexecutes duringdocker build(install),CMDexecutes duringdocker run(start). Only the lastCMDtakes effect.EXPOSEvs-p—EXPOSEis documentation for humans and tooling;docker run -p 3000:3000actually publishes the port to the host. Without-p, the port is not reachable from outside.
Layer caching: Each instruction creates a layer cached by Docker. If the Dockerfile hasn't changed up to a layer, the cache is reused. This is why order matters — COPY package*.json + RUN npm ci before COPY . . means source changes don't invalidate the dependency layer, cutting rebuilds from 60s to 12s.
How to Write a Dockerfile Step-by-Step (Beginner)
Step 1: Choose a Base Image (FROM)
Pick the language runtime plus a slim OS. node:20-alpine (110 MB) is 3× smaller than node:20 (350 MB) and faster to pull, with a smaller attack surface. For Python, python:3.11-slim is the equivalent; for static sites, nginx:alpine (25 MB). Pin the version (don't use latest) for reproducibility.
Step 2: Set Workdir (WORKDIR)
WORKDIR /app creates /app if missing and makes it the current directory for subsequent COPY and RUN. It is idempotent and clearer than RUN mkdir -p /app && cd /app.
Step 3: Copy Dependencies First, Then Source (Cache Optimization)
COPY package*.json ./
RUN npm ci --only=production
COPY . .
This order is the single most impactful optimization. Dependency manifests change rarely; source changes often. Copying manifests and installing before the full source means dependency layers are cached across source edits. Reversing the order — COPY . . before npm ci — invalidates the cache on every source change.
Step 4: Document Port and Define Run Command
EXPOSE 3000 declares the port the app listens on. CMD ["node", "server.js"] is the default process (exec form, JSON array, preferred over shell form CMD node server.js because it handles signals correctly). Only one CMD is allowed.
Step 5: Build and Run (Two Commands)
docker build -t myapp:1.0 .
docker images # see myapp:1.0
docker run -d -p 3000:3000 --name app myapp:1.0
docker logs -f app # follow logs
# Open http://localhost:3000
The minimal 7-line Dockerfile for a Node app:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Dockerfile Best Practices — Smaller, Faster, Safer
- Use a small base:
node:20-alpineorpython:3.11-slimcuts size by 3× and speeds pulls. - Leverage layer cache: Dependencies before source, as above. Combine related
RUNsteps with&&to reduce layers where appropriate. - Add
.dockerignore: Excludenode_modules, .git, .env, Dockerfileto shrink build context, speed uploads, and prevent leaking secrets:node_modules .git .env Dockerfile npm-debug.log - Use multi-stage for production: Build stage includes compilers, runtime stage is tiny:
Final image contains only runtime, not build tools — 5× smaller.FROM node:20 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci && npm run build FROM node:20-alpine WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules CMD ["node", "dist/server.js"] - Security: Do not
COPY .env(use--env-fileat run), run as non-root (RUN adduser -D app && USER app), pin base digests (node:20-alpine@sha256:...), and scan withdocker scout cvesper Docker security best practices.
Common Mistakes to Avoid
COPY . .beforenpm ci— breaks cache, slow rebuilds- Using
ADDfor plain copy — preferCOPY - Running as root — add
USER app - Forgetting
.dockerignore— large context and secret leakage - Using
latesttag — non-reproducible builds
Dockerfile Examples — Node, Python, Static
Node.js (Express / Next.js)
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Python (Flask / FastAPI)
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "app.py"]
Static Site (Nginx)
FROM nginx:alpine
COPY dist/ /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
For a built frontend (React/Vite), build locally then copy dist/ into the Nginx image — 25 MB total.
Using a Dockerfile Generator (No Syntax to Memorize)
Select the stack (Node, Python, Nginx), set the port and run command, and copy the generated Dockerfile. The generator applies the cache-optimal order, adds a starter .dockerignore, and chooses the slim base automatically — no docs needed. Change the base and CMD to adapt across stacks; the pattern remains the same.
FAQs About Writing Dockerfiles
What is the difference between COPY and ADD?
Use COPY for copying files. ADD also auto-extracts tar archives and can fetch URLs, which is often surprising. Docker's own best practices recommend COPY for explicit behavior.
Why should package files be copied before source?
To leverage layer caching. COPY package*.json + RUN npm ci before COPY . . means dependency layers are reused when only source changes, cutting rebuilds from ~60s to ~12s.
What does EXPOSE do?
EXPOSE documents the port the container listens on; it does not publish it to the host. Publish with docker run -p 3000:3000 or ports: in Compose.
How do I make my Docker image smaller?
Use an Alpine or slim base, add a .dockerignore, leverage cache ordering, and use multi-stage builds so the final image contains only runtime artifacts, not build tools.
Should I run as root in Docker?
No — add a non-root user with RUN adduser -D app && USER app to reduce risk if the app is compromised.
How do I build and run the Dockerfile?
docker build -t myapp:1.0 . builds the image; docker run -d -p 3000:3000 myapp:1.0 runs it detached with port publishing. Check docker logs -f <container> for output.
Conclusion
Writing a Dockerfile is a 7-line pattern: base, workdir, copy deps, install, copy source, expose, and run. Master the order for caching, add a .dockerignore, and use multi-stage when size matters. From there, the same structure adapts across Node, Python, and static stacks.
Generate the next Dockerfile with a visual builder — select the stack, set the port and command, and copy a correct file in seconds.