Dockerfile Guide: Build Custom Docker Images from Scratch
Learn how to write a Dockerfile to containerise your application. Covers FROM, RUN, COPY, ENV, EXPOSE, CMD, ENTRYPOINT, multi-stage builds, and Docker build best practices.

Running pre-built images from Docker Hub is useful, but the real power of Docker comes from packaging your own application. A Dockerfile is a text file of build instructions — Docker reads it top-to-bottom and produces an image. This lesson covers every important Dockerfile instruction and the best practices that keep images small, secure, and fast to build.
Dockerfile Basics
A Dockerfile is named exactly Dockerfile (no extension) and lives in your project root. Here's a minimal example for a Node.js application:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]Build it with:
docker build -t myapp:1.0 .The . at the end is the build context — the directory Docker sends to the daemon. The daemon reads the Dockerfile and builds the image.
Dockerfile Instructions Reference
FROM — Base Image
FROM node:20-alpine
FROM python:3.12-slim
FROM ubuntu:22.04Every Dockerfile must start with FROM. Choose the smallest appropriate base: alpine variants are tiny (~5MB), slim variants are stripped-down Debian/Ubuntu images and are a good default, and full ubuntu or debian images include many tools you don't need in production.
WORKDIR — Set Working Directory
WORKDIR /appSets the current directory inside the image for all subsequent instructions. Creates the directory if it doesn't exist. Prefer WORKDIR over RUN mkdir && cd.
COPY — Copy Files into the Image
COPY package*.json ./
COPY src/ ./src/
COPY . .Key pattern: copy only package.json and package-lock.json before running npm install. This way, the dependency layer is only rebuilt when those files change — not every time your source code changes.
RUN — Execute Commands During Build
RUN npm ci --only=production
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*RUN executes a command and commits the result as a new layer. Combine related commands with && to keep them in a single layer:
# Bad: three layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Good: one layer
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*ENV — Set Environment Variables
ENV NODE_ENV=production
ENV PORT=3000Sets environment variables available both during the build and at runtime. For sensitive values (passwords, API keys), use runtime -e flags or Kubernetes Secrets — never bake them into the image.
ARG — Build-Time Arguments
ARG APP_VERSION=1.0.0
RUN echo "Building version $APP_VERSION"ARG values are passed at build time with --build-arg and are not available in the final running container:
docker build --build-arg APP_VERSION=2.1.0 -t myapp:2.1.0 .EXPOSE — Document a Port
EXPOSE 3000Documents which port the container listens on. This is metadata only — it doesn't actually publish the port. You still need -p in docker run to make the port accessible from outside.
CMD — Default Command
CMD ["node", "src/index.js"]Specifies the default command to run when the container starts. Use the exec form (JSON array) rather than shell form — exec form doesn't invoke a shell, which means signals like SIGTERM reach your process directly. CMD can be overridden at docker run time.
ENTRYPOINT — Fixed Start Command
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "src/index.js"]ENTRYPOINT sets the executable; CMD provides its default arguments. Arguments passed to docker run replace CMD but are appended to ENTRYPOINT.
USER — Non-Root User
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuserBy default containers run as root — a security risk. Create a non-root user and switch to it before the final CMD.
.dockerignore — Exclude Files from Build Context
Create a .dockerignore file in your project root to exclude files from the build context:
node_modules
.git
.env
*.log
dist
coverageExcluding node_modules is especially important — without it, your entire local node_modules folder gets sent to the daemon on every build.
Multi-Stage Builds
Multi-stage builds produce small production images by separating the build environment from the runtime environment:
# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production image
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]The final image contains only the Alpine base, production dependencies, and compiled code — not the full Node.js build tools, TypeScript compiler, or source files. Images built this way are typically 5–10x smaller.
Complete Example: Node.js REST API
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package*.json ./
RUN npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS production
ENV NODE_ENV=production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]Build and run:
docker build -t myapi:1.0 .
docker run -d -p 3000:3000 --name my-api myapi:1.0
curl http://localhost:3000/healthDockerfile Best Practices
| Practice | Why |
|---|---|
Use specific version tags (node:20-alpine, not node:latest) | Reproducible builds |
| Order instructions from least to most frequently changing | Maximise layer cache hits |
Combine related RUN commands with && | Fewer layers, smaller image |
Copy only what's needed (package.json before source) | Faster rebuilds |
| Use multi-stage builds for compiled languages | Smaller final images |
| Run as a non-root user | Security |
Add a HEALTHCHECK | Orchestrators know when your app is ready |
Maintain a .dockerignore | Faster builds, no accidental secret leaks |
Previous: Lesson 3 — Docker Images & Containers | Next: Lesson 5 — Docker Compose
Part of the Docker & Kubernetes Mastery course.
External references:
