Dockerfile Explained Line by Line (Beginner Edition)

Posted November 22, 2025 by Karol Polakowski

A Dockerfile is the recipe that tells Docker how to build an image. For beginners it can feel like magic: one file produces a reproducible container image. This article walks through a simple, practical Dockerfile line by line, explains common pitfalls, and provides actionable tips so you can write efficient, maintainable Dockerfiles.

Minimal example (context)

Below is a compact example you can adapt for a Node.js app. Place it at the project root alongside package.json and your app source.

# syntax=docker/dockerfile:1
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
ENV NODE_ENV=production
USER node
CMD ["node", "index.js"]

Line-by-line explanation

# syntax=…

  • Optional directive to select Dockerfile frontend/buildkit features. Useful when you need BuildKit-specific syntax (like secrets, mount=type=cache). Place it as the first line.

FROM node:18-alpine AS base

  • FROM selects the base image. Here we use the official Node.js 18 variant on Alpine (small).
  • The AS base part creates a named stage; helpful for multi-stage builds. Single-stage builds can omit AS.
  • Best practice: pin major (or full) versions to avoid surprise upgrades: e.g., node:18.17.1-alpine.

WORKDIR /app

  • Sets the working directory for subsequent instructions and runtime defaults. If the directory doesn’t exist, Docker creates it.
  • Avoid using absolute paths in COPY later; rely on WORKDIR for clarity.

COPY package*.json ./

  • Copies package.json and package-lock.json (if present) into the image. Copying dependency manifests separately enables Docker layer caching for npm install.
  • Prefer precise globs to avoid copying unintended files.

RUN npm ci –only=production

  • RUN executes a command in the image and creates a new layer.
  • npm ci is deterministic and faster in CI contexts; –only=production avoids devDependencies in production images.
  • Combine related RUN steps when appropriate to reduce layers and keep the image small. Example: RUN apk add –no-cache git && npm ci.

COPY . .

  • Adds your application source into the image.
  • Use .dockerignore to exclude node_modules, logs, local config, and build artifacts. This keeps build context small and prevents leaking secrets.

EXPOSE 3000

  • Metadata declaring the port your app listens on. EXPOSE doesn’t publish the port to the host by itself — you still need docker run -p or container orchestration port mappings.

ENV NODE_ENV=production

  • Sets environment variables baked into the image. ENV is persisted in the image and used at runtime unless overridden.
  • For secrets use runtime mechanisms (secrets manager, environment variables at deploy time) — don’t bake secrets into images.

USER node

  • Switches user for subsequent instructions and runtime. Running as non-root is a security best practice.
  • Ensure files/dirs you need are owned or accessible by that user. You may need chown during build for files created as root.

CMD [“node”, “index.js”]

  • Default command executed when the container starts. Use exec form (JSON array) to avoid shell interpretation.
  • Distinguish CMD vs ENTRYPOINT:
  • ENTRYPOINT sets the executable; CMD provides default arguments. If you want the container to behave like a specific binary (but allow args injection), combine ENTRYPOINT + CMD.

Important instructions not in the minimal example

ADD vs COPY

  • COPY is simple and explicit: copies files from context. Prefer it.
  • ADD can unpack local tar archives and fetch remote URLs. Avoid ADD unless you need those specific behaviors.

ARG vs ENV

  • ARG values exist only at build-time and are not persisted in final image metadata.
  • ENV variables persist into the image and are available at runtime.
  • Use ARG for build-time configuration (e.g., build-time tokens — still avoid secrets), and ENV for runtime config defaults.

Multi-stage builds

  • Useful to separate build environment and runtime image. Build heavy toolchains in one stage, copy only final artifacts to a slim runtime stage.
  • Example pattern: builder stage runs npm run build, final stage copies dist/ and installs only production deps.

Caching and layer strategy

  • Docker caches layers by instruction. Place frequently changing steps (like COPY . .) after low-change steps (like copying package.json + installing deps) to reuse cached layers.
  • Combine commands with && and clear caches when appropriate (e.g., apk add and rm caches in same RUN) to reduce image size.

HEALTHCHECK

  • Add HEALTHCHECK to let orchestrators know when your app is healthy. Eg: HEALTHCHECK –interval=30s CMD curl -f http://localhost:3000/health || exit 1

LABEL

  • Use LABEL to store metadata (maintainer, version, source). Eg: LABEL maintainer=”you@example.com” version=”1.0″

Practical best practices checklist

  • Use .dockerignore to reduce build context.
  • Pin base image versions.
  • Prefer smaller base images (alpine, distroless) but measure compatibility and image size.
  • Run as non-root when possible.
  • Keep image layers minimal and leverage caching by ordering instructions.
  • Don’t bake secrets into images; use environment variables, secret managers, or BuildKit secrets.
  • Test image locally with docker run -p 3000:3000 IMAGE and inspect with docker history to see layers.

Troubleshooting tips

  • If rebuilds seem slow, ensure .dockerignore excludes large folders and node_modules.
  • Use docker build –no-cache . only when you need a fresh build; otherwise rely on cache.
  • Inspect the image: docker run –rm -it –entrypoint sh IMAGE to poke around.
  • If permissions cause problems after switching USER, add appropriate chown steps during the build.

Final notes

Start small and iterate: begin with a straightforward Dockerfile, then profile and optimize for size and build speed as needed. The most critical wins come from using .dockerignore correctly, ordering COPY/RUN for cache-friendliness, and separating build/runtime with multi-stage builds.