DevOpsDocker

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.

TT
Daniel Brooks
6 min read
Dockerfile Guide: Build Custom Docker Images from Scratch

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:

dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]

Build it with:

bash
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

dockerfile
FROM node:20-alpine
FROM python:3.12-slim
FROM ubuntu:22.04

Every 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

dockerfile
WORKDIR /app

Sets 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

dockerfile
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

dockerfile
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:

dockerfile
# 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

dockerfile
ENV NODE_ENV=production
ENV PORT=3000

Sets 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

dockerfile
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:

bash
docker build --build-arg APP_VERSION=2.1.0 -t myapp:2.1.0 .

EXPOSE — Document a Port

dockerfile
EXPOSE 3000

Documents 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

dockerfile
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

dockerfile
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

dockerfile
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

By 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:

text
node_modules
.git
.env
*.log
dist
coverage

Excluding 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:

dockerfile
# 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

dockerfile
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:

bash
docker build -t myapi:1.0 .
docker run -d -p 3000:3000 --name my-api myapi:1.0
curl http://localhost:3000/health

Dockerfile Best Practices

PracticeWhy
Use specific version tags (node:20-alpine, not node:latest)Reproducible builds
Order instructions from least to most frequently changingMaximise 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 languagesSmaller final images
Run as a non-root userSecurity
Add a HEALTHCHECKOrchestrators know when your app is ready
Maintain a .dockerignoreFaster 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: