All Tools View Categories Blog About Contact Privacy

How to Write a Dockerfile: A Beginner's Step-by-Step Guide

How to Write a Dockerfile: A Beginner's Step-by-Step Guide

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.

TL;DR — Quick Answer: A minimal Dockerfile needs 6 instructions: 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.
How to write a Dockerfile - beginner step-by-step guide with 6 instructions

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 explained

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.

InstructionWhat It DoesExample
FROMBase image (OS + runtime) — must be firstFROM node:20-alpine
WORKDIRCreates and sets working directory (mkdir + cd)WORKDIR /app
COPYCopies files from build context into imageCOPY package*.json ./
RUNExecutes at build time (install deps)RUN npm ci --only=production
EXPOSEDocuments container port (does not publish)EXPOSE 3000
CMDDefault command at run time (one per Dockerfile)CMD ["node", "server.js"]

Key distinctions:

  • COPY vs ADD — Use COPY. ADD auto-extracts tar archives and fetches URLs, which is surprising; COPY is explicit per Docker best practices.
  • RUN vs CMDRUN executes during docker build (install), CMD executes during docker run (start). Only the last CMD takes effect.
  • EXPOSE vs -pEXPOSE is documentation for humans and tooling; docker run -p 3000:3000 actually 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)

How to write a Dockerfile step-by-step - 5 steps from base to run

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

Dockerfile best practices - base size, cache, dockerignore and security
  1. Use a small base: node:20-alpine or python:3.11-slim cuts size by 3× and speeds pulls.
  2. Leverage layer cache: Dependencies before source, as above. Combine related RUN steps with && to reduce layers where appropriate.
  3. Add .dockerignore: Exclude node_modules, .git, .env, Dockerfile to shrink build context, speed uploads, and prevent leaking secrets:
    node_modules
    .git
    .env
    Dockerfile
    npm-debug.log
  4. Use multi-stage for production: Build stage includes compilers, runtime stage is tiny:
    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"]
    Final image contains only runtime, not build tools — 5× smaller.
  5. Security: Do not COPY .env (use --env-file at run), run as non-root (RUN adduser -D app && USER app), pin base digests (node:20-alpine@sha256:...), and scan with docker scout cves per Docker security best practices.

Common Mistakes to Avoid

  • COPY . . before npm ci — breaks cache, slow rebuilds
  • Using ADD for plain copy — prefer COPY
  • Running as root — add USER app
  • Forgetting .dockerignore — large context and secret leakage
  • Using latest tag — non-reproducible builds

Dockerfile Examples — Node, Python, Static

Dockerfile examples for Node.js, Python and static Nginx

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.